@simple-auth-kit/cli 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/combos/express-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/express-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
- package/registry/combos/express-drizzle/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/config}/auth.config.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
- package/registry/combos/express-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/express-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
- package/registry/combos/{nestjs-drizzle/shared/src → express-drizzle/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/express-drizzle/shared/src/middleware/auth-core-error.middleware.ts +6 -8
- package/registry/combos/express-drizzle/shared/src/middleware/auth.middleware.ts +6 -8
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/openapi}/openapi-fragment.ts +13 -4
- package/registry/combos/express-drizzle/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +251 -52
- package/registry/combos/express-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/express-drizzle/shared/src/repositories/session.repository.ts +3 -3
- package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/route-tiers.ts +52 -11
- package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +2 -2
- package/registry/combos/express-drizzle/test/bootstrap.ts +4 -2
- package/registry/combos/express-drizzle/test/prove-cycle.ts +1095 -308
- package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +5 -5
- package/registry/combos/express-drizzle/variants/base/src/index.ts +8 -0
- package/registry/combos/express-drizzle/variants/base/src/middleware/authz.middleware.ts +2 -2
- package/registry/combos/express-drizzle/variants/base/src/openapi/openapi-admin.ts +1082 -0
- package/registry/combos/express-drizzle/variants/base/src/rbac.defaults.ts +60 -17
- package/registry/combos/express-drizzle/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-drizzle/variants/base/src/repositories/rbac.repository.ts +5 -5
- package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-drizzle/variants/base/src/seed.ts +76 -17
- package/registry/combos/express-drizzle/variants/base/src/services/auth.service.ts +7 -9
- package/registry/combos/express-drizzle/variants/base/test/variant-hooks.ts +167 -45
- package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +5 -5
- package/registry/combos/express-drizzle/variants/workspaces/src/index.ts +8 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi/openapi-admin.ts +1176 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +1 -1
- package/registry/combos/express-drizzle/variants/workspaces/src/rbac.defaults.ts +66 -19
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +5 -5
- package/registry/combos/express-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +9 -11
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +1 -1
- package/registry/combos/express-drizzle/variants/workspaces/src/seed.ts +168 -35
- package/registry/combos/express-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
- package/registry/combos/express-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
- package/registry/combos/express-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/express-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +25 -9
- package/registry/combos/express-prisma/shared/src/{rate-limit.store.ts → cache/rate-limit.store.ts} +4 -1
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/config}/auth.config.ts +1 -1
- package/registry/combos/express-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/{nestjs-prisma/shared/src → express-prisma/shared/src/helpers}/id.helper.ts +6 -2
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/express-prisma/shared/src/middleware/auth-core-error.middleware.ts +6 -8
- package/registry/combos/express-prisma/shared/src/middleware/auth.middleware.ts +6 -8
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/openapi}/openapi-fragment.ts +13 -4
- package/registry/combos/express-prisma/shared/src/{openapi-spec.ts → openapi/openapi-spec.ts} +313 -55
- package/registry/combos/express-prisma/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/express-prisma/shared/src/repositories/session.repository.ts +2 -2
- package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/express-prisma/shared/src/route-tiers.ts +52 -11
- package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +2 -2
- package/registry/combos/express-prisma/test/bootstrap.ts +7 -3
- package/registry/combos/express-prisma/test/prove-cycle.ts +1095 -308
- package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +6 -6
- package/registry/combos/express-prisma/variants/base/src/index.ts +8 -0
- package/registry/combos/express-prisma/variants/base/src/middleware/authz.middleware.ts +2 -2
- package/registry/combos/express-prisma/variants/base/src/openapi/openapi-admin.ts +1102 -0
- package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +1 -1
- package/registry/combos/express-prisma/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/base/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-prisma/variants/base/src/seed.ts +1 -1
- package/registry/combos/express-prisma/variants/base/src/services/auth.service.ts +6 -8
- package/registry/combos/express-prisma/variants/base/test/variant-hooks.ts +176 -47
- package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +6 -6
- package/registry/combos/express-prisma/variants/workspaces/src/index.ts +8 -0
- package/registry/combos/express-prisma/variants/workspaces/src/middleware/authz.middleware.ts +3 -3
- package/registry/combos/express-prisma/variants/workspaces/src/openapi/openapi-admin.ts +1218 -0
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/express-prisma/variants/workspaces/src/repositories/workspace.repository.ts +2 -2
- package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +18 -22
- package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +1 -1
- package/registry/combos/express-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
- package/registry/combos/express-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
- package/registry/combos/nestjs-drizzle/shared/src/{guards → ability}/ability.guard.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/nestjs-drizzle/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
- package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/cache}/rate-limit.store.ts +4 -1
- package/registry/combos/nestjs-drizzle/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{db.ts → config/db.ts} +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/nestjs-drizzle/shared/src/controllers/auth.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/guards/auth.guard.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/{id.helper.ts → helpers/id.helper.ts} +6 -2
- package/registry/combos/{express-prisma/shared/src → nestjs-drizzle/shared/src/helpers}/pagination.ts +17 -3
- package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +2 -2
- package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +8 -12
- package/registry/combos/nestjs-drizzle/shared/src/repositories/session.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/shared/src/repositories/two-factor.repository.ts +1 -1
- package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/bootstrap.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-drizzle/variants/base/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/base/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-drizzle/variants/base/src/index.ts +9 -0
- package/registry/combos/nestjs-drizzle/variants/base/src/rbac.defaults.ts +58 -16
- package/registry/combos/nestjs-drizzle/variants/base/src/repositories/audit-log.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/variants/base/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/nestjs-drizzle/variants/base/src/seed.ts +76 -17
- package/registry/combos/nestjs-drizzle/variants/base/src/services/auth.service.ts +7 -9
- package/registry/combos/nestjs-drizzle/variants/base/test/variant-hooks.ts +167 -45
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/index.ts +9 -0
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/rbac.defaults.ts +64 -18
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/audit-log.repository.ts +3 -3
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/rbac.repository.ts +4 -4
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/repositories/workspace.repository.ts +8 -10
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/seed.ts +171 -36
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +7 -9
- package/registry/combos/nestjs-drizzle/variants/workspaces/test/variant-hooks.ts +539 -133
- package/registry/combos/nestjs-prisma/shared/src/{guards → ability}/ability.guard.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/{ability.ts → ability/ability.ts} +8 -2
- package/registry/combos/nestjs-prisma/shared/src/{permission-cache.ts → cache/permission-cache.ts} +27 -10
- package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/cache}/rate-limit.store.ts +4 -1
- package/registry/combos/nestjs-prisma/shared/src/{auth.config.ts → config/auth.config.ts} +1 -1
- package/registry/combos/nestjs-prisma/shared/src/{key-provider.ts → config/key-provider.ts} +6 -2
- package/registry/combos/nestjs-prisma/shared/src/controllers/auth.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/guards/auth.guard.ts +1 -1
- package/registry/combos/{express-prisma/shared/src → nestjs-prisma/shared/src/helpers}/id.helper.ts +6 -2
- package/registry/combos/nestjs-prisma/shared/src/{pagination.ts → helpers/pagination.ts} +17 -3
- package/registry/combos/nestjs-prisma/shared/src/repositories/oauth.repository.ts +1 -1
- package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +2 -4
- package/registry/combos/nestjs-prisma/shared/src/repositories/session.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-prisma/test/bootstrap.ts +1 -1
- package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-prisma/variants/base/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/gateways/audit-log.gateway.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/index.ts +9 -0
- package/registry/combos/nestjs-prisma/variants/base/src/{docs.ts → openapi/docs.ts} +23 -6
- package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/country.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/customer.repository.ts +6 -5
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/language.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/base/src/repositories/rbac.repository.ts +3 -3
- package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +6 -8
- package/registry/combos/nestjs-prisma/variants/base/test/variant-hooks.ts +176 -47
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +10 -6
- package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/admin.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/controllers/workspace.controller.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/guards/authz.guard.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/index.ts +9 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/audit-log.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/country.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/customer.repository.ts +6 -5
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +2 -2
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/rbac.repository.ts +3 -3
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/workspace.repository.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +1 -1
- package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +6 -8
- package/registry/combos/nestjs-prisma/variants/workspaces/test/variant-hooks.ts +538 -134
- package/registry/combos/express-drizzle/variants/base/src/openapi-admin.ts +0 -565
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-admin.ts +0 -596
- package/registry/combos/express-prisma/variants/base/src/openapi-admin.ts +0 -569
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-admin.ts +0 -601
- /package/registry/combos/express-drizzle/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
- /package/registry/combos/express-prisma/shared/src/{http-error.ts → errors/http-error.ts} +0 -0
|
@@ -9,10 +9,18 @@
|
|
|
9
9
|
import "dotenv/config";
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { generateTotpCode } from "@/lib/auth/core/two-factor.js";
|
|
12
|
-
import { ABILITY_SUBJECT, defineAbilitiesFor } from "../src/ability.js";
|
|
12
|
+
import { ABILITY_SUBJECT, defineAbilitiesFor } from "../src/ability/ability.js";
|
|
13
13
|
import { ability, createTieredRouter } from "../src/route-tiers.js";
|
|
14
14
|
import { bootstrap, capturedResetTokens } from "./bootstrap.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
adminRouteProbes,
|
|
17
|
+
AuthTokens,
|
|
18
|
+
BASE_PORT,
|
|
19
|
+
CallOptions,
|
|
20
|
+
decodeJwtPayload,
|
|
21
|
+
ProofContext,
|
|
22
|
+
RouteProbe,
|
|
23
|
+
} from "./harness.js";
|
|
16
24
|
import { hooks } from "./variant-hooks.js";
|
|
17
25
|
|
|
18
26
|
const BASE = `http://localhost:${BASE_PORT}`;
|
|
@@ -27,7 +35,11 @@ function assert(condition: boolean, message: string) {
|
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
async function call(
|
|
38
|
+
async function call(
|
|
39
|
+
method: string,
|
|
40
|
+
path: string,
|
|
41
|
+
opts: CallOptions = {},
|
|
42
|
+
): Promise<{ status: number; body: any }> {
|
|
31
43
|
const res = await fetch(`${BASE}${path}`, {
|
|
32
44
|
method,
|
|
33
45
|
headers: {
|
|
@@ -43,7 +55,10 @@ async function call(method: string, path: string, opts: CallOptions = {}): Promi
|
|
|
43
55
|
// message, ...}` (error) — unwrap `data` here, once, so every assertion below reads the actual
|
|
44
56
|
// payload without knowing the envelope exists. Error bodies have no `data` key, so they pass
|
|
45
57
|
// through unchanged.
|
|
46
|
-
const body =
|
|
58
|
+
const body =
|
|
59
|
+
envelope && typeof envelope === "object" && "data" in envelope
|
|
60
|
+
? envelope.data
|
|
61
|
+
: envelope;
|
|
47
62
|
return { status: res.status, body: body as any };
|
|
48
63
|
}
|
|
49
64
|
|
|
@@ -54,7 +69,10 @@ const ROLE_SLUG = `billing-manager-${RUN_ID}`;
|
|
|
54
69
|
|
|
55
70
|
async function signup(email: string, password: string): Promise<AuthTokens> {
|
|
56
71
|
const res = await call("POST", "/auth/signup", { body: { email, password } });
|
|
57
|
-
if (res.status !== 200 && res.status !== 201)
|
|
72
|
+
if (res.status !== 200 && res.status !== 201)
|
|
73
|
+
throw new Error(
|
|
74
|
+
`signup failed (${res.status}): ${JSON.stringify(res.body)}`,
|
|
75
|
+
);
|
|
58
76
|
return res.body as AuthTokens;
|
|
59
77
|
}
|
|
60
78
|
|
|
@@ -74,13 +92,22 @@ const ctx: ProofContext = { call, assert, signup, uniqueEmail };
|
|
|
74
92
|
* no one can reach.
|
|
75
93
|
*/
|
|
76
94
|
function registerRouteGatedOnAnUndefinedSlug(): unknown {
|
|
77
|
-
const passthrough = (_req: unknown, _res: unknown, next: () => void) =>
|
|
78
|
-
|
|
95
|
+
const passthrough = (_req: unknown, _res: unknown, next: () => void) =>
|
|
96
|
+
next();
|
|
97
|
+
const router = createTieredRouter({
|
|
98
|
+
authentication: passthrough as never,
|
|
99
|
+
authorization: passthrough as never,
|
|
100
|
+
});
|
|
79
101
|
try {
|
|
80
102
|
// Cast because the whole point is that TypeScript rejects this; the check being proved is the
|
|
81
103
|
// runtime one, which catches a caller who is not type-checked at all.
|
|
82
|
-
router.route(
|
|
83
|
-
|
|
104
|
+
router.route(
|
|
105
|
+
"get",
|
|
106
|
+
"/__tier-proof",
|
|
107
|
+
ability("no:such-permission" as never),
|
|
108
|
+
((_req: unknown, res: { json: (b: unknown) => void }) =>
|
|
109
|
+
res.json({ ok: true })) as never,
|
|
110
|
+
);
|
|
84
111
|
return undefined;
|
|
85
112
|
} catch (err) {
|
|
86
113
|
return err;
|
|
@@ -88,26 +115,44 @@ function registerRouteGatedOnAnUndefinedSlug(): unknown {
|
|
|
88
115
|
}
|
|
89
116
|
|
|
90
117
|
async function main() {
|
|
91
|
-
console.log(
|
|
118
|
+
console.log(
|
|
119
|
+
"0. fail-closed at startup: a route gated on a permission nothing can grant stops the app from booting",
|
|
120
|
+
);
|
|
92
121
|
const bootError = registerRouteGatedOnAnUndefinedSlug();
|
|
93
|
-
const bootMessage =
|
|
94
|
-
|
|
122
|
+
const bootMessage =
|
|
123
|
+
bootError instanceof Error ? bootError.message : String(bootError);
|
|
95
124
|
assert(
|
|
96
|
-
|
|
125
|
+
bootError !== undefined,
|
|
126
|
+
"registering a route gated on a slug outside PERMISSION_CATALOG throws at registration, not at request time",
|
|
127
|
+
);
|
|
128
|
+
assert(
|
|
129
|
+
bootMessage.includes("no:such-permission") &&
|
|
130
|
+
bootMessage.includes("PERMISSION_CATALOG"),
|
|
97
131
|
`…and the error names the offending permission (got: ${bootMessage.split("\n")[0]})`,
|
|
98
132
|
);
|
|
99
133
|
|
|
100
134
|
const app = await bootstrap(BASE_PORT);
|
|
101
|
-
assert(
|
|
135
|
+
assert(
|
|
136
|
+
true,
|
|
137
|
+
"…and a router whose routes all name catalog slugs builds fine — the check is about the slug, not the boot path",
|
|
138
|
+
);
|
|
102
139
|
console.log(`variant: ${hooks.variant}\n`);
|
|
103
140
|
|
|
104
141
|
try {
|
|
105
142
|
console.log("1. signup (session A)");
|
|
106
143
|
const emailA = uniqueEmail("a");
|
|
107
|
-
const signupA = await call("POST", "/auth/signup", {
|
|
108
|
-
|
|
144
|
+
const signupA = await call("POST", "/auth/signup", {
|
|
145
|
+
body: { email: emailA, password: "correct-horse-battery" },
|
|
146
|
+
});
|
|
147
|
+
assert(
|
|
148
|
+
signupA.status === 201 || signupA.status === 200,
|
|
149
|
+
`signup succeeds with only email + password in the body (got ${signupA.status})`,
|
|
150
|
+
);
|
|
109
151
|
const tokensA = signupA.body as AuthTokens;
|
|
110
|
-
assert(
|
|
152
|
+
assert(
|
|
153
|
+
!!tokensA.accessToken && !!tokensA.refreshToken,
|
|
154
|
+
"signup returns access + refresh tokens",
|
|
155
|
+
);
|
|
111
156
|
|
|
112
157
|
// Asserted as an exact set rather than a list of absences, and kept exact on purpose: this is
|
|
113
158
|
// the assertion that stops authorization data creeping back into the token later. There is no
|
|
@@ -119,36 +164,76 @@ async function main() {
|
|
|
119
164
|
Object.keys(claimsA).sort().join(",") === "exp,iat,jti,sessionId,sub",
|
|
120
165
|
`the access token carries exactly the expected claims — identity and session only (got ${Object.keys(claimsA).sort().join(",")})`,
|
|
121
166
|
);
|
|
122
|
-
assert(
|
|
167
|
+
assert(
|
|
168
|
+
claimsA.sub ===
|
|
169
|
+
(await call("GET", "/auth/me", { token: tokensA.accessToken })).body
|
|
170
|
+
.sub,
|
|
171
|
+
"the token's subject is the user",
|
|
172
|
+
);
|
|
123
173
|
|
|
124
|
-
const meSignup = await call("GET", "/auth/me", {
|
|
174
|
+
const meSignup = await call("GET", "/auth/me", {
|
|
175
|
+
token: tokensA.accessToken,
|
|
176
|
+
});
|
|
125
177
|
assert(
|
|
126
|
-
hooks.variant === "workspaces"
|
|
178
|
+
hooks.variant === "workspaces"
|
|
179
|
+
? meSignup.body.roles.length === 0
|
|
180
|
+
: meSignup.body.roles.length > 0,
|
|
127
181
|
`a new signup's roles come from the database, not the token (got ${JSON.stringify(meSignup.body.roles)})`,
|
|
128
182
|
);
|
|
129
183
|
|
|
130
184
|
console.log("2. login (session B, a second device for the same user)");
|
|
131
|
-
const loginB = await call("POST", "/auth/login", {
|
|
185
|
+
const loginB = await call("POST", "/auth/login", {
|
|
186
|
+
body: { identifier: emailA, password: "correct-horse-battery" },
|
|
187
|
+
});
|
|
132
188
|
const tokensB = loginB.body as AuthTokens;
|
|
133
|
-
assert(
|
|
134
|
-
|
|
189
|
+
assert(
|
|
190
|
+
loginB.status === 201 || loginB.status === 200,
|
|
191
|
+
`login succeeds (got ${loginB.status})`,
|
|
192
|
+
);
|
|
193
|
+
assert(
|
|
194
|
+
tokensB.sessionId !== tokensA.sessionId,
|
|
195
|
+
"login creates a distinct session from signup's",
|
|
196
|
+
);
|
|
135
197
|
|
|
136
198
|
console.log("2b. login with wrong password is rejected");
|
|
137
|
-
const badLogin = await call("POST", "/auth/login", {
|
|
138
|
-
|
|
199
|
+
const badLogin = await call("POST", "/auth/login", {
|
|
200
|
+
body: { identifier: emailA, password: "wrong" },
|
|
201
|
+
});
|
|
202
|
+
assert(
|
|
203
|
+
badLogin.status === 401,
|
|
204
|
+
`wrong password rejected (got ${badLogin.status})`,
|
|
205
|
+
);
|
|
139
206
|
|
|
140
|
-
console.log(
|
|
141
|
-
|
|
142
|
-
|
|
207
|
+
console.log(
|
|
208
|
+
"2c. a second signup with the same email is rejected — email is globally unique",
|
|
209
|
+
);
|
|
210
|
+
const duplicate = await call("POST", "/auth/signup", {
|
|
211
|
+
body: { email: emailA, password: "another-password-1" },
|
|
212
|
+
});
|
|
213
|
+
assert(
|
|
214
|
+
duplicate.status === 409,
|
|
215
|
+
`duplicate email rejected (got ${duplicate.status})`,
|
|
216
|
+
);
|
|
143
217
|
|
|
144
|
-
console.log(
|
|
218
|
+
console.log(
|
|
219
|
+
"3. protected route: GET /auth/me with session A's access token",
|
|
220
|
+
);
|
|
145
221
|
const meA = await call("GET", "/auth/me", { token: tokensA.accessToken });
|
|
146
|
-
assert(
|
|
147
|
-
|
|
222
|
+
assert(
|
|
223
|
+
meA.status === 200,
|
|
224
|
+
`me succeeds with a valid token (got ${meA.status})`,
|
|
225
|
+
);
|
|
226
|
+
assert(
|
|
227
|
+
meA.body?.sessionId === tokensA.sessionId,
|
|
228
|
+
"me reflects session A's identity",
|
|
229
|
+
);
|
|
148
230
|
|
|
149
231
|
console.log("3b. protected route rejects a missing token");
|
|
150
232
|
const meNoAuth = await call("GET", "/auth/me");
|
|
151
|
-
assert(
|
|
233
|
+
assert(
|
|
234
|
+
meNoAuth.status === 401,
|
|
235
|
+
`me rejects no token (got ${meNoAuth.status})`,
|
|
236
|
+
);
|
|
152
237
|
|
|
153
238
|
console.log("3c. the three route tiers, from the outside");
|
|
154
239
|
// Tier 1: no token at all. Each of these is marked @Public() in the source; the assertion is
|
|
@@ -157,211 +242,487 @@ async function main() {
|
|
|
157
242
|
// running, not the guard refusing.
|
|
158
243
|
const MISSING_TOKEN = "missing bearer token";
|
|
159
244
|
const publicProbes: Array<[string, string, unknown]> = [
|
|
160
|
-
[
|
|
245
|
+
[
|
|
246
|
+
"POST",
|
|
247
|
+
"/auth/login",
|
|
248
|
+
{ identifier: emailA, password: "wrong-on-purpose" },
|
|
249
|
+
],
|
|
161
250
|
["POST", "/auth/refresh", { refreshToken: "not-a-real-token" }],
|
|
162
251
|
["POST", "/auth/password/forgot", { email: uniqueEmail("nobody") }],
|
|
163
252
|
["POST", "/auth/login/2fa", { challengeToken: "nope", code: "000000" }],
|
|
164
|
-
[
|
|
253
|
+
[
|
|
254
|
+
"POST",
|
|
255
|
+
"/auth/password/reset",
|
|
256
|
+
{ token: "nope", newPassword: "irrelevant-1" },
|
|
257
|
+
],
|
|
165
258
|
["GET", "/auth/oauth/google/start", undefined],
|
|
166
259
|
];
|
|
167
260
|
for (const [method, path, body] of publicProbes) {
|
|
168
261
|
const res = await call(method, path, { body });
|
|
169
|
-
assert(
|
|
262
|
+
assert(
|
|
263
|
+
res.body?.message !== MISSING_TOKEN,
|
|
264
|
+
`${method} ${path} is reachable with no token (got ${res.status} ${res.body?.message ?? ""})`,
|
|
265
|
+
);
|
|
170
266
|
}
|
|
171
267
|
|
|
172
268
|
// Tier 2: any authenticated caller, no permission. `tokensA` holds no administrative
|
|
173
269
|
// permission whatsoever, and these must still answer.
|
|
174
270
|
for (const path of ["/auth/me", "/auth/sessions"]) {
|
|
175
271
|
const authed = await call("GET", path, { token: tokensA.accessToken });
|
|
176
|
-
assert(
|
|
272
|
+
assert(
|
|
273
|
+
authed.status === 200,
|
|
274
|
+
`GET ${path} is open to any authenticated user (got ${authed.status})`,
|
|
275
|
+
);
|
|
177
276
|
const anon = await call("GET", path);
|
|
178
|
-
assert(
|
|
277
|
+
assert(
|
|
278
|
+
anon.status === 401,
|
|
279
|
+
`…and refuses an anonymous caller (got ${anon.status})`,
|
|
280
|
+
);
|
|
179
281
|
}
|
|
180
282
|
|
|
181
283
|
console.log("4. refresh session A's token");
|
|
182
|
-
const refreshed = await call("POST", "/auth/refresh", {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
assert(
|
|
284
|
+
const refreshed = await call("POST", "/auth/refresh", {
|
|
285
|
+
body: { refreshToken: tokensA.refreshToken },
|
|
286
|
+
});
|
|
287
|
+
assert(
|
|
288
|
+
refreshed.status === 200 || refreshed.status === 201,
|
|
289
|
+
`refresh succeeds (got ${refreshed.status})`,
|
|
290
|
+
);
|
|
291
|
+
const rotatedA = refreshed.body as {
|
|
292
|
+
accessToken: string;
|
|
293
|
+
refreshToken: string;
|
|
294
|
+
};
|
|
295
|
+
assert(
|
|
296
|
+
rotatedA.refreshToken !== tokensA.refreshToken,
|
|
297
|
+
"refresh issues a new refresh token (rotation)",
|
|
298
|
+
);
|
|
186
299
|
|
|
187
|
-
console.log(
|
|
188
|
-
|
|
189
|
-
|
|
300
|
+
console.log(
|
|
301
|
+
"4b. reuse of the now-dead original refresh token is detected and kills the whole session family",
|
|
302
|
+
);
|
|
303
|
+
const reuse = await call("POST", "/auth/refresh", {
|
|
304
|
+
body: { refreshToken: tokensA.refreshToken },
|
|
305
|
+
});
|
|
306
|
+
assert(
|
|
307
|
+
reuse.status === 401,
|
|
308
|
+
`stale/reused refresh token rejected (got ${reuse.status})`,
|
|
309
|
+
);
|
|
190
310
|
|
|
191
|
-
const reuseFalloutB = await call("POST", "/auth/refresh", {
|
|
192
|
-
|
|
311
|
+
const reuseFalloutB = await call("POST", "/auth/refresh", {
|
|
312
|
+
body: { refreshToken: tokensB.refreshToken },
|
|
313
|
+
});
|
|
314
|
+
assert(
|
|
315
|
+
reuseFalloutB.status === 401,
|
|
316
|
+
`reuse detection also killed session B's refresh (got ${reuseFalloutB.status})`,
|
|
317
|
+
);
|
|
193
318
|
|
|
194
319
|
// Self-contained on purpose: the reuse detection just above kills every session belonging to
|
|
195
320
|
// user A, so anything asserting on a *live* session has to bring its own.
|
|
196
|
-
console.log(
|
|
197
|
-
|
|
321
|
+
console.log(
|
|
322
|
+
"4c. a session's lifetime: an absolute expiry that refreshing does not extend",
|
|
323
|
+
);
|
|
324
|
+
type LiveSession = {
|
|
325
|
+
id: string;
|
|
326
|
+
createdAt: string;
|
|
327
|
+
expiresAt: string;
|
|
328
|
+
provider?: string;
|
|
329
|
+
};
|
|
198
330
|
const lifetimeEmail = uniqueEmail("lifetime");
|
|
199
331
|
const lifetimeFirst = await signup(lifetimeEmail, "lifetime-pw-123");
|
|
200
|
-
const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken)
|
|
332
|
+
const lifetimeSessionId = decodeJwtPayload(lifetimeFirst.accessToken)
|
|
333
|
+
.sessionId as string;
|
|
201
334
|
|
|
202
|
-
const listSessions = async (token: string) =>
|
|
203
|
-
|
|
335
|
+
const listSessions = async (token: string) =>
|
|
336
|
+
(await call("GET", "/auth/sessions", { token })).body as LiveSession[];
|
|
337
|
+
const live = (await listSessions(lifetimeFirst.accessToken)).find(
|
|
338
|
+
(s) => s.id === lifetimeSessionId,
|
|
339
|
+
);
|
|
204
340
|
assert(!!live, "the caller's own session is listed");
|
|
205
341
|
assert(
|
|
206
|
-
!!live &&
|
|
342
|
+
!!live &&
|
|
343
|
+
new Date(live.expiresAt).getTime() > new Date(live.createdAt).getTime(),
|
|
207
344
|
`a session expires strictly after it was created (got ${live?.createdAt} → ${live?.expiresAt})`,
|
|
208
345
|
);
|
|
209
|
-
assert(
|
|
346
|
+
assert(
|
|
347
|
+
!live?.provider,
|
|
348
|
+
"a password login records no OAuth provider on the session",
|
|
349
|
+
);
|
|
210
350
|
|
|
211
351
|
// The point of `expires_at` being written once and never rewritten: rotation issues a fresh
|
|
212
352
|
// refresh token every time, so without an absolute cap a continuously active client would hold
|
|
213
353
|
// one session open forever.
|
|
214
|
-
const lifetimeRotated = (
|
|
215
|
-
|
|
216
|
-
|
|
354
|
+
const lifetimeRotated = (
|
|
355
|
+
await call("POST", "/auth/refresh", {
|
|
356
|
+
body: { refreshToken: lifetimeFirst.refreshToken },
|
|
357
|
+
})
|
|
358
|
+
).body as AuthTokens;
|
|
359
|
+
const afterRefresh = (await listSessions(lifetimeRotated.accessToken)).find(
|
|
360
|
+
(s) => s.id === lifetimeSessionId,
|
|
361
|
+
);
|
|
362
|
+
assert(
|
|
363
|
+
afterRefresh?.expiresAt === live?.expiresAt,
|
|
364
|
+
`refreshing does not push the session's expiry out (got ${afterRefresh?.expiresAt})`,
|
|
365
|
+
);
|
|
217
366
|
|
|
218
367
|
// A second device for the same user, so the list can be read after the first one is revoked.
|
|
219
|
-
const lifetimeSecond = (
|
|
220
|
-
|
|
368
|
+
const lifetimeSecond = (
|
|
369
|
+
await call("POST", "/auth/login", {
|
|
370
|
+
body: { identifier: lifetimeEmail, password: "lifetime-pw-123" },
|
|
371
|
+
})
|
|
372
|
+
).body as AuthTokens;
|
|
373
|
+
assert(
|
|
374
|
+
(await listSessions(lifetimeSecond.accessToken)).some(
|
|
375
|
+
(s) => s.id === lifetimeSessionId,
|
|
376
|
+
),
|
|
377
|
+
"a second device sees the first device's session",
|
|
378
|
+
);
|
|
221
379
|
|
|
222
380
|
await call("POST", "/auth/logout", { token: lifetimeRotated.accessToken });
|
|
223
381
|
// Revocation is never a delete: the row survives with `is_revoked` set, and what changes is
|
|
224
382
|
// that it stops counting as active.
|
|
225
383
|
assert(
|
|
226
|
-
!(await listSessions(lifetimeSecond.accessToken)).some(
|
|
384
|
+
!(await listSessions(lifetimeSecond.accessToken)).some(
|
|
385
|
+
(s) => s.id === lifetimeSessionId,
|
|
386
|
+
),
|
|
227
387
|
"a revoked session stops being listed as active",
|
|
228
388
|
);
|
|
229
389
|
|
|
230
|
-
console.log(
|
|
231
|
-
|
|
232
|
-
|
|
390
|
+
console.log(
|
|
391
|
+
"5. logout-one-device: revoke session A specifically using its (rotated) access token",
|
|
392
|
+
);
|
|
393
|
+
const logoutA = await call("POST", "/auth/logout", {
|
|
394
|
+
token: rotatedA.accessToken,
|
|
395
|
+
});
|
|
396
|
+
assert(
|
|
397
|
+
logoutA.status === 200 || logoutA.status === 201,
|
|
398
|
+
`logout succeeds (got ${logoutA.status})`,
|
|
399
|
+
);
|
|
233
400
|
|
|
234
|
-
const meAAfterLogout = await call("GET", "/auth/me", {
|
|
235
|
-
|
|
401
|
+
const meAAfterLogout = await call("GET", "/auth/me", {
|
|
402
|
+
token: rotatedA.accessToken,
|
|
403
|
+
});
|
|
404
|
+
assert(
|
|
405
|
+
meAAfterLogout.status === 401,
|
|
406
|
+
`session A's access token is denylisted after logout (got ${meAAfterLogout.status})`,
|
|
407
|
+
);
|
|
236
408
|
|
|
237
409
|
console.log("6. block/unblock (admin-scoped)");
|
|
238
410
|
const adminEmail = uniqueEmail("admin");
|
|
239
411
|
const adminSignup = await signup(adminEmail, "admin-pw-123");
|
|
240
|
-
const adminUserId = (
|
|
241
|
-
|
|
412
|
+
const adminUserId = (
|
|
413
|
+
await call("GET", "/auth/me", { token: adminSignup.accessToken })
|
|
414
|
+
).body.sub as string;
|
|
415
|
+
const admin = await hooks.makeAdmin(ctx, {
|
|
416
|
+
userId: adminUserId,
|
|
417
|
+
email: adminEmail,
|
|
418
|
+
password: "admin-pw-123",
|
|
419
|
+
tokens: adminSignup,
|
|
420
|
+
});
|
|
242
421
|
|
|
243
422
|
const victimEmail = uniqueEmail("victim");
|
|
244
423
|
const victimTokens = await signup(victimEmail, "victim-pw-123");
|
|
245
|
-
const victimId = (
|
|
424
|
+
const victimId = (
|
|
425
|
+
await call("GET", "/auth/me", { token: victimTokens.accessToken })
|
|
426
|
+
).body.sub as string;
|
|
246
427
|
await hooks.admitUser(ctx, admin, { userId: victimId, email: victimEmail });
|
|
247
428
|
|
|
248
429
|
console.log("6a. a non-admin cannot block anyone");
|
|
249
|
-
const forbiddenBlock = await call(
|
|
250
|
-
|
|
430
|
+
const forbiddenBlock = await call(
|
|
431
|
+
"POST",
|
|
432
|
+
`/auth/admin/users/${victimId}/block`,
|
|
433
|
+
{ token: victimTokens.accessToken, workspaceId: admin.workspaceId },
|
|
434
|
+
);
|
|
435
|
+
assert(
|
|
436
|
+
forbiddenBlock.status === 403,
|
|
437
|
+
`non-admin block attempt rejected (got ${forbiddenBlock.status})`,
|
|
438
|
+
);
|
|
251
439
|
|
|
252
440
|
console.log("6b. an admin cannot block themselves");
|
|
253
|
-
const selfBlock = await call(
|
|
254
|
-
|
|
441
|
+
const selfBlock = await call(
|
|
442
|
+
"POST",
|
|
443
|
+
`/auth/admin/users/${adminUserId}/block`,
|
|
444
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
445
|
+
);
|
|
446
|
+
assert(
|
|
447
|
+
selfBlock.status === 403,
|
|
448
|
+
`admin self-block rejected (got ${selfBlock.status})`,
|
|
449
|
+
);
|
|
255
450
|
|
|
256
451
|
console.log("6b-ii. an admin cannot revoke their own role");
|
|
257
452
|
// Self-revoking `admin` strips the permission that authorised the call. In a workspace
|
|
258
453
|
// there is no way back in afterwards, so the route refuses it the same way self-block does.
|
|
259
|
-
const selfRevoke = await call(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
454
|
+
const selfRevoke = await call(
|
|
455
|
+
"POST",
|
|
456
|
+
`/auth/admin/users/${adminUserId}/roles/admin/revoke`,
|
|
457
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
458
|
+
);
|
|
459
|
+
assert(
|
|
460
|
+
selfRevoke.status === 403,
|
|
461
|
+
`admin self-role-revoke rejected (got ${selfRevoke.status})`,
|
|
462
|
+
);
|
|
463
|
+
const stillAdmin = await call("GET", "/auth/admin/users", {
|
|
464
|
+
token: await admin.freshToken(),
|
|
465
|
+
workspaceId: admin.workspaceId,
|
|
466
|
+
});
|
|
467
|
+
assert(
|
|
468
|
+
stillAdmin.status === 200,
|
|
469
|
+
`admin still authorised after the refused self-revoke (got ${stillAdmin.status})`,
|
|
470
|
+
);
|
|
263
471
|
|
|
264
472
|
console.log("6c. admin blocks the victim");
|
|
265
|
-
const block = await call("POST", `/auth/admin/users/${victimId}/block`, {
|
|
266
|
-
|
|
473
|
+
const block = await call("POST", `/auth/admin/users/${victimId}/block`, {
|
|
474
|
+
token: await admin.freshToken(),
|
|
475
|
+
workspaceId: admin.workspaceId,
|
|
476
|
+
});
|
|
477
|
+
assert(
|
|
478
|
+
block.status === 200 || block.status === 201,
|
|
479
|
+
`block succeeds (got ${block.status})`,
|
|
480
|
+
);
|
|
267
481
|
|
|
268
|
-
console.log(
|
|
269
|
-
|
|
270
|
-
|
|
482
|
+
console.log(
|
|
483
|
+
"7. get-rejected-after-block: victim cannot log in or refresh anymore",
|
|
484
|
+
);
|
|
485
|
+
const victimLoginAfterBlock = await call("POST", "/auth/login", {
|
|
486
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
487
|
+
});
|
|
488
|
+
assert(
|
|
489
|
+
victimLoginAfterBlock.status === 401,
|
|
490
|
+
`blocked user cannot log in (got ${victimLoginAfterBlock.status})`,
|
|
491
|
+
);
|
|
271
492
|
|
|
272
|
-
const victimRefreshAfterBlock = await call("POST", "/auth/refresh", {
|
|
273
|
-
|
|
493
|
+
const victimRefreshAfterBlock = await call("POST", "/auth/refresh", {
|
|
494
|
+
body: { refreshToken: victimTokens.refreshToken },
|
|
495
|
+
});
|
|
496
|
+
assert(
|
|
497
|
+
victimRefreshAfterBlock.status === 401,
|
|
498
|
+
`blocked user's refresh token rejected (got ${victimRefreshAfterBlock.status})`,
|
|
499
|
+
);
|
|
274
500
|
|
|
275
501
|
console.log("7b. unblock restores access");
|
|
276
|
-
const unblock = await call(
|
|
277
|
-
|
|
278
|
-
|
|
502
|
+
const unblock = await call(
|
|
503
|
+
"POST",
|
|
504
|
+
`/auth/admin/users/${victimId}/unblock`,
|
|
505
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
506
|
+
);
|
|
279
507
|
assert(
|
|
280
|
-
|
|
508
|
+
unblock.status === 200 || unblock.status === 201,
|
|
509
|
+
`unblock succeeds (got ${unblock.status})`,
|
|
510
|
+
);
|
|
511
|
+
const victimLoginAfterUnblock = await call("POST", "/auth/login", {
|
|
512
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
513
|
+
});
|
|
514
|
+
assert(
|
|
515
|
+
victimLoginAfterUnblock.status === 200 ||
|
|
516
|
+
victimLoginAfterUnblock.status === 201,
|
|
281
517
|
`unblocked user can log in again (got ${victimLoginAfterUnblock.status})`,
|
|
282
518
|
);
|
|
283
519
|
|
|
284
|
-
console.log(
|
|
285
|
-
|
|
286
|
-
|
|
520
|
+
console.log(
|
|
521
|
+
"7c. deactivate is a distinct toggle from block — both independently deny login, neither implies the other",
|
|
522
|
+
);
|
|
523
|
+
const deactivate = await call(
|
|
524
|
+
"POST",
|
|
525
|
+
`/auth/admin/users/${victimId}/deactivate`,
|
|
526
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
527
|
+
);
|
|
528
|
+
assert(
|
|
529
|
+
deactivate.status === 200 || deactivate.status === 201,
|
|
530
|
+
`deactivate succeeds (got ${deactivate.status})`,
|
|
531
|
+
);
|
|
287
532
|
|
|
288
|
-
const victimLoginAfterDeactivate = await call("POST", "/auth/login", {
|
|
289
|
-
|
|
533
|
+
const victimLoginAfterDeactivate = await call("POST", "/auth/login", {
|
|
534
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
535
|
+
});
|
|
536
|
+
assert(
|
|
537
|
+
victimLoginAfterDeactivate.status === 401,
|
|
538
|
+
`deactivated (but not blocked) user cannot log in either (got ${victimLoginAfterDeactivate.status})`,
|
|
539
|
+
);
|
|
290
540
|
|
|
291
|
-
const usersAfterDeactivate = await call("GET", "/auth/admin/users", {
|
|
292
|
-
|
|
293
|
-
|
|
541
|
+
const usersAfterDeactivate = await call("GET", "/auth/admin/users", {
|
|
542
|
+
token: await admin.freshToken(),
|
|
543
|
+
workspaceId: admin.workspaceId,
|
|
544
|
+
});
|
|
545
|
+
const victimAfterDeactivate = (
|
|
546
|
+
usersAfterDeactivate.body as {
|
|
547
|
+
items: Array<{
|
|
548
|
+
id?: string;
|
|
549
|
+
userId?: string;
|
|
550
|
+
blocked: boolean;
|
|
551
|
+
isActive: boolean;
|
|
552
|
+
}>;
|
|
553
|
+
}
|
|
554
|
+
).items.find((u) => (u.id ?? u.userId) === victimId);
|
|
555
|
+
assert(
|
|
556
|
+
victimAfterDeactivate?.isActive === false,
|
|
557
|
+
"…and the user list reflects isActive=false",
|
|
558
|
+
);
|
|
559
|
+
assert(
|
|
560
|
+
victimAfterDeactivate?.blocked === false,
|
|
561
|
+
"…while blocked stays false — deactivating never sets it, they are independent flags",
|
|
294
562
|
);
|
|
295
|
-
assert(victimAfterDeactivate?.isActive === false, "…and the user list reflects isActive=false");
|
|
296
|
-
assert(victimAfterDeactivate?.blocked === false, "…while blocked stays false — deactivating never sets it, they are independent flags");
|
|
297
563
|
|
|
298
|
-
const reactivate = await call(
|
|
299
|
-
|
|
300
|
-
|
|
564
|
+
const reactivate = await call(
|
|
565
|
+
"POST",
|
|
566
|
+
`/auth/admin/users/${victimId}/activate`,
|
|
567
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
568
|
+
);
|
|
569
|
+
assert(
|
|
570
|
+
reactivate.status === 200 || reactivate.status === 201,
|
|
571
|
+
`activate succeeds (got ${reactivate.status})`,
|
|
572
|
+
);
|
|
573
|
+
const victimLoginAfterReactivate = await call("POST", "/auth/login", {
|
|
574
|
+
body: { identifier: victimEmail, password: "victim-pw-123" },
|
|
575
|
+
});
|
|
301
576
|
assert(
|
|
302
|
-
victimLoginAfterReactivate.status === 200 ||
|
|
577
|
+
victimLoginAfterReactivate.status === 200 ||
|
|
578
|
+
victimLoginAfterReactivate.status === 201,
|
|
303
579
|
`reactivated user can log in again (got ${victimLoginAfterReactivate.status})`,
|
|
304
580
|
);
|
|
305
581
|
|
|
306
|
-
console.log(
|
|
582
|
+
console.log(
|
|
583
|
+
"8. two-factor: enroll -> confirm -> login now requires 2FA -> complete challenge -> logout",
|
|
584
|
+
);
|
|
307
585
|
const tfaEmail = uniqueEmail("tfa");
|
|
308
586
|
const tfaTokens = await signup(tfaEmail, "tfa-pw-12345");
|
|
309
587
|
|
|
310
|
-
const enroll = await call("POST", "/auth/2fa/enroll", {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
588
|
+
const enroll = await call("POST", "/auth/2fa/enroll", {
|
|
589
|
+
token: tfaTokens.accessToken,
|
|
590
|
+
});
|
|
591
|
+
assert(
|
|
592
|
+
enroll.status === 200 || enroll.status === 201,
|
|
593
|
+
`2fa enroll succeeds (got ${enroll.status})`,
|
|
594
|
+
);
|
|
595
|
+
const { secret } = enroll.body as {
|
|
596
|
+
secret: string;
|
|
597
|
+
provisioningUri: string;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const confirm = await call("POST", "/auth/2fa/confirm", {
|
|
601
|
+
token: tfaTokens.accessToken,
|
|
602
|
+
body: { code: generateTotpCode(secret) },
|
|
603
|
+
});
|
|
604
|
+
assert(
|
|
605
|
+
confirm.status === 200 || confirm.status === 201,
|
|
606
|
+
`2fa confirm succeeds (got ${confirm.status})`,
|
|
607
|
+
);
|
|
316
608
|
const { backupCodes } = confirm.body as { backupCodes: string[] };
|
|
317
|
-
assert(
|
|
609
|
+
assert(
|
|
610
|
+
backupCodes.length === 10,
|
|
611
|
+
`2fa confirm returns backup codes (got ${backupCodes.length})`,
|
|
612
|
+
);
|
|
318
613
|
|
|
319
|
-
const tfaLogin = await call("POST", "/auth/login", {
|
|
320
|
-
|
|
321
|
-
|
|
614
|
+
const tfaLogin = await call("POST", "/auth/login", {
|
|
615
|
+
body: { identifier: tfaEmail, password: "tfa-pw-12345" },
|
|
616
|
+
});
|
|
617
|
+
const tfaChallenge = tfaLogin.body as {
|
|
618
|
+
twoFactorRequired: true;
|
|
619
|
+
challengeToken: string;
|
|
620
|
+
};
|
|
621
|
+
assert(
|
|
622
|
+
tfaChallenge.twoFactorRequired === true,
|
|
623
|
+
`login now returns a 2fa challenge instead of tokens (got status ${tfaLogin.status})`,
|
|
624
|
+
);
|
|
322
625
|
|
|
323
|
-
const tfaComplete = await call("POST", "/auth/login/2fa", {
|
|
324
|
-
|
|
626
|
+
const tfaComplete = await call("POST", "/auth/login/2fa", {
|
|
627
|
+
body: {
|
|
628
|
+
challengeToken: tfaChallenge.challengeToken,
|
|
629
|
+
code: generateTotpCode(secret),
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
assert(
|
|
633
|
+
tfaComplete.status === 200 || tfaComplete.status === 201,
|
|
634
|
+
`2fa challenge completes login (got ${tfaComplete.status})`,
|
|
635
|
+
);
|
|
325
636
|
const tfaSessionTokens = tfaComplete.body as { accessToken: string };
|
|
326
637
|
|
|
327
|
-
const tfaBadChallenge = await call("POST", "/auth/login/2fa", {
|
|
328
|
-
|
|
638
|
+
const tfaBadChallenge = await call("POST", "/auth/login/2fa", {
|
|
639
|
+
body: { challengeToken: tfaChallenge.challengeToken, code: "000000" },
|
|
640
|
+
});
|
|
641
|
+
assert(
|
|
642
|
+
tfaBadChallenge.status === 401,
|
|
643
|
+
`wrong 2fa code rejected (got ${tfaBadChallenge.status})`,
|
|
644
|
+
);
|
|
329
645
|
|
|
330
|
-
const tfaLogout = await call("POST", "/auth/logout", {
|
|
331
|
-
|
|
646
|
+
const tfaLogout = await call("POST", "/auth/logout", {
|
|
647
|
+
token: tfaSessionTokens.accessToken,
|
|
648
|
+
});
|
|
649
|
+
assert(
|
|
650
|
+
tfaLogout.status === 200 || tfaLogout.status === 201,
|
|
651
|
+
`logout after 2fa login succeeds (got ${tfaLogout.status})`,
|
|
652
|
+
);
|
|
332
653
|
|
|
333
|
-
console.log(
|
|
654
|
+
console.log(
|
|
655
|
+
"9. forgot/reset password -> old password stops working -> old sessions revoked",
|
|
656
|
+
);
|
|
334
657
|
const resetEmail = uniqueEmail("reset");
|
|
335
658
|
const resetTokens = await signup(resetEmail, "original-password-1");
|
|
336
659
|
|
|
337
|
-
const forgot = await call("POST", "/auth/password/forgot", {
|
|
338
|
-
|
|
660
|
+
const forgot = await call("POST", "/auth/password/forgot", {
|
|
661
|
+
body: { email: resetEmail },
|
|
662
|
+
});
|
|
663
|
+
assert(
|
|
664
|
+
forgot.status === 200 || forgot.status === 201,
|
|
665
|
+
`forgot-password always reports success (got ${forgot.status})`,
|
|
666
|
+
);
|
|
339
667
|
const rawResetToken = capturedResetTokens.get(resetEmail);
|
|
340
|
-
assert(
|
|
668
|
+
assert(
|
|
669
|
+
!!rawResetToken,
|
|
670
|
+
"the reset token reached the sendPasswordResetEmail hook",
|
|
671
|
+
);
|
|
341
672
|
|
|
342
|
-
const badReset = await call("POST", "/auth/password/reset", {
|
|
343
|
-
|
|
673
|
+
const badReset = await call("POST", "/auth/password/reset", {
|
|
674
|
+
body: { token: "not-a-real-token", newPassword: "irrelevant-1" },
|
|
675
|
+
});
|
|
676
|
+
assert(
|
|
677
|
+
badReset.status === 401,
|
|
678
|
+
`reset with an invalid token is rejected (got ${badReset.status})`,
|
|
679
|
+
);
|
|
344
680
|
|
|
345
|
-
const reset = await call("POST", "/auth/password/reset", {
|
|
346
|
-
|
|
681
|
+
const reset = await call("POST", "/auth/password/reset", {
|
|
682
|
+
body: { token: rawResetToken, newPassword: "new-password-2" },
|
|
683
|
+
});
|
|
684
|
+
assert(
|
|
685
|
+
reset.status === 200 || reset.status === 201,
|
|
686
|
+
`reset with a valid token succeeds (got ${reset.status})`,
|
|
687
|
+
);
|
|
347
688
|
|
|
348
689
|
// Session revocation bumps sessionVersion/revokedAt, which invalidates future *refreshes*
|
|
349
690
|
// immediately; it does not retroactively invalidate an already-issued, still-unexpired
|
|
350
691
|
// access token (same "denylist is for instant per-token kill, sessionVersion is for
|
|
351
692
|
// refresh-time revocation" split that /auth/logout relies on elsewhere in this file).
|
|
352
|
-
const refreshAfterReset = await call("POST", "/auth/refresh", {
|
|
353
|
-
|
|
693
|
+
const refreshAfterReset = await call("POST", "/auth/refresh", {
|
|
694
|
+
body: { refreshToken: resetTokens.refreshToken },
|
|
695
|
+
});
|
|
696
|
+
assert(
|
|
697
|
+
refreshAfterReset.status === 401,
|
|
698
|
+
`pre-reset session's refresh token is invalid after password reset (got ${refreshAfterReset.status})`,
|
|
699
|
+
);
|
|
354
700
|
|
|
355
|
-
const loginWithOldPassword = await call("POST", "/auth/login", {
|
|
356
|
-
|
|
701
|
+
const loginWithOldPassword = await call("POST", "/auth/login", {
|
|
702
|
+
body: { identifier: resetEmail, password: "original-password-1" },
|
|
703
|
+
});
|
|
704
|
+
assert(
|
|
705
|
+
loginWithOldPassword.status === 401,
|
|
706
|
+
`old password no longer works (got ${loginWithOldPassword.status})`,
|
|
707
|
+
);
|
|
357
708
|
|
|
358
|
-
const loginWithNewPassword = await call("POST", "/auth/login", {
|
|
359
|
-
|
|
709
|
+
const loginWithNewPassword = await call("POST", "/auth/login", {
|
|
710
|
+
body: { identifier: resetEmail, password: "new-password-2" },
|
|
711
|
+
});
|
|
712
|
+
assert(
|
|
713
|
+
loginWithNewPassword.status === 200 ||
|
|
714
|
+
loginWithNewPassword.status === 201,
|
|
715
|
+
`new password works (got ${loginWithNewPassword.status})`,
|
|
716
|
+
);
|
|
360
717
|
|
|
361
|
-
console.log(
|
|
718
|
+
console.log(
|
|
719
|
+
"10. RBAC: create role, attach permission, assign to user, permission takes effect",
|
|
720
|
+
);
|
|
362
721
|
const rbacEmail = uniqueEmail("rbac");
|
|
363
722
|
const rbacTokens = await signup(rbacEmail, "rbac-pw-12345");
|
|
364
|
-
const rbacUserId = (
|
|
723
|
+
const rbacUserId = (
|
|
724
|
+
await call("GET", "/auth/me", { token: rbacTokens.accessToken })
|
|
725
|
+
).body.sub as string;
|
|
365
726
|
await hooks.admitUser(ctx, admin, { userId: rbacUserId, email: rbacEmail });
|
|
366
727
|
|
|
367
728
|
const createRole = await call("POST", "/auth/admin/roles", {
|
|
@@ -369,75 +730,172 @@ async function main() {
|
|
|
369
730
|
workspaceId: admin.workspaceId,
|
|
370
731
|
body: { slug: ROLE_SLUG, displayName: "Billing manager" },
|
|
371
732
|
});
|
|
372
|
-
assert(
|
|
373
|
-
|
|
374
|
-
|
|
733
|
+
assert(
|
|
734
|
+
createRole.status === 200 || createRole.status === 201,
|
|
735
|
+
`admin can create a role (got ${createRole.status})`,
|
|
736
|
+
);
|
|
737
|
+
const role = createRole.body as {
|
|
738
|
+
id: string;
|
|
739
|
+
slug: string;
|
|
740
|
+
displayName: string;
|
|
741
|
+
};
|
|
742
|
+
assert(
|
|
743
|
+
role.slug === ROLE_SLUG && role.displayName === "Billing manager",
|
|
744
|
+
"a role carries a stable slug and a separate human label",
|
|
745
|
+
);
|
|
375
746
|
|
|
376
|
-
const attachPermission = await call(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
747
|
+
const attachPermission = await call(
|
|
748
|
+
"POST",
|
|
749
|
+
`/auth/admin/roles/${role.id}/permissions`,
|
|
750
|
+
{
|
|
751
|
+
token: await admin.freshToken(),
|
|
752
|
+
workspaceId: admin.workspaceId,
|
|
753
|
+
body: { permission: "billing:manage" },
|
|
754
|
+
},
|
|
755
|
+
);
|
|
756
|
+
assert(
|
|
757
|
+
attachPermission.status === 200 || attachPermission.status === 201,
|
|
758
|
+
`admin can attach a permission to a role (got ${attachPermission.status})`,
|
|
759
|
+
);
|
|
382
760
|
|
|
383
|
-
const assignRole = await call(
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
761
|
+
const assignRole = await call(
|
|
762
|
+
"POST",
|
|
763
|
+
`/auth/admin/users/${rbacUserId}/roles`,
|
|
764
|
+
{
|
|
765
|
+
token: await admin.freshToken(),
|
|
766
|
+
workspaceId: admin.workspaceId,
|
|
767
|
+
body: { role: ROLE_SLUG },
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
assert(
|
|
771
|
+
assignRole.status === 200 || assignRole.status === 201,
|
|
772
|
+
`admin can assign a role to a user (got ${assignRole.status})`,
|
|
773
|
+
);
|
|
389
774
|
|
|
390
|
-
const rbacDirect = await call(
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
775
|
+
const rbacDirect = await call(
|
|
776
|
+
"POST",
|
|
777
|
+
`/auth/admin/users/${rbacUserId}/permissions`,
|
|
778
|
+
{
|
|
779
|
+
token: await admin.freshToken(),
|
|
780
|
+
workspaceId: admin.workspaceId,
|
|
781
|
+
body: { permission: "reports:export" },
|
|
782
|
+
},
|
|
783
|
+
);
|
|
784
|
+
assert(
|
|
785
|
+
rbacDirect.status === 200 || rbacDirect.status === 201,
|
|
786
|
+
`admin can grant a permission directly (got ${rbacDirect.status})`,
|
|
787
|
+
);
|
|
396
788
|
|
|
397
789
|
// No re-login. Nothing about authorization lives in the token in either variant, so both
|
|
398
790
|
// grants are in effect on the caller's very next request, on the token they already hold.
|
|
399
|
-
const rbacMe = await call("GET", "/auth/me", {
|
|
400
|
-
|
|
401
|
-
assert((rbacMe.body?.permissions ?? []).includes("reports:export"), "a direct grant is unioned with the role-derived permissions");
|
|
402
|
-
assert((rbacMe.body?.roles ?? []).includes(ROLE_SLUG), "…and /auth/me reports the role by slug");
|
|
403
|
-
|
|
404
|
-
console.log("11. admin audit log: the role_assigned event from step 10 is readable back");
|
|
405
|
-
const auditList = await call("GET", `/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`, {
|
|
406
|
-
token: await admin.freshToken(),
|
|
791
|
+
const rbacMe = await call("GET", "/auth/me", {
|
|
792
|
+
token: rbacTokens.accessToken,
|
|
407
793
|
workspaceId: admin.workspaceId,
|
|
408
794
|
});
|
|
409
|
-
assert(auditList.status === 200, `admin can list the audit log (got ${auditList.status})`);
|
|
410
|
-
const auditEntries = (auditList.body as { items: Array<{ action: string; name: string; userId: string | null }> }).items;
|
|
411
795
|
assert(
|
|
412
|
-
|
|
796
|
+
(rbacMe.body?.permissions ?? []).includes("billing:manage"),
|
|
797
|
+
"the assigned role's permission is in effect on the user's existing token",
|
|
798
|
+
);
|
|
799
|
+
assert(
|
|
800
|
+
(rbacMe.body?.permissions ?? []).includes("reports:export"),
|
|
801
|
+
"a direct grant is unioned with the role-derived permissions",
|
|
802
|
+
);
|
|
803
|
+
assert(
|
|
804
|
+
(rbacMe.body?.roles ?? []).includes(ROLE_SLUG),
|
|
805
|
+
"…and /auth/me reports the role by slug",
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
console.log(
|
|
809
|
+
"11. admin audit log: the role_assigned event from step 10 is readable back",
|
|
810
|
+
);
|
|
811
|
+
const auditList = await call(
|
|
812
|
+
"GET",
|
|
813
|
+
`/auth/admin/audit-log?userId=${rbacUserId}&action=role_assigned`,
|
|
814
|
+
{
|
|
815
|
+
token: await admin.freshToken(),
|
|
816
|
+
workspaceId: admin.workspaceId,
|
|
817
|
+
},
|
|
818
|
+
);
|
|
819
|
+
assert(
|
|
820
|
+
auditList.status === 200,
|
|
821
|
+
`admin can list the audit log (got ${auditList.status})`,
|
|
822
|
+
);
|
|
823
|
+
const auditEntries = (
|
|
824
|
+
auditList.body as {
|
|
825
|
+
items: Array<{ action: string; name: string; userId: string | null }>;
|
|
826
|
+
}
|
|
827
|
+
).items;
|
|
828
|
+
assert(
|
|
829
|
+
auditEntries.some(
|
|
830
|
+
(e) => e.action === "role_assigned" && e.userId === rbacUserId,
|
|
831
|
+
),
|
|
413
832
|
"the role_assigned event for this user shows up in the audit log",
|
|
414
833
|
);
|
|
415
|
-
assert(
|
|
834
|
+
assert(
|
|
835
|
+
auditEntries.every((e) => e.name === "Role assigned"),
|
|
836
|
+
"audit entries carry a human-readable name alongside the action",
|
|
837
|
+
);
|
|
416
838
|
|
|
417
|
-
const auditForbidden = await call("GET", "/auth/admin/audit-log", {
|
|
418
|
-
|
|
839
|
+
const auditForbidden = await call("GET", "/auth/admin/audit-log", {
|
|
840
|
+
token: rbacTokens.accessToken,
|
|
841
|
+
workspaceId: admin.workspaceId,
|
|
842
|
+
});
|
|
843
|
+
assert(
|
|
844
|
+
auditForbidden.status === 403,
|
|
845
|
+
`non-admin cannot list the audit log (got ${auditForbidden.status})`,
|
|
846
|
+
);
|
|
419
847
|
|
|
420
|
-
console.log(
|
|
848
|
+
console.log(
|
|
849
|
+
"11b. user/role CRUD: fetch, edit, and soft-delete both — on disposable rows, so the shared probe target/role are untouched",
|
|
850
|
+
);
|
|
421
851
|
const crudUserEmail = uniqueEmail("crud-user");
|
|
422
852
|
const crudUserTokens = await signup(crudUserEmail, "crud-user-pw-12345");
|
|
423
|
-
const crudUserId = (
|
|
424
|
-
|
|
853
|
+
const crudUserId = (
|
|
854
|
+
await call("GET", "/auth/me", { token: crudUserTokens.accessToken })
|
|
855
|
+
).body.sub as string;
|
|
856
|
+
await hooks.admitUser(ctx, admin, {
|
|
857
|
+
userId: crudUserId,
|
|
858
|
+
email: crudUserEmail,
|
|
859
|
+
});
|
|
425
860
|
|
|
426
|
-
const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, {
|
|
427
|
-
|
|
428
|
-
|
|
861
|
+
const getUser = await call("GET", `/auth/admin/users/${crudUserId}`, {
|
|
862
|
+
token: await admin.freshToken(),
|
|
863
|
+
workspaceId: admin.workspaceId,
|
|
864
|
+
});
|
|
865
|
+
assert(
|
|
866
|
+
getUser.status === 200,
|
|
867
|
+
`admin can fetch a single user (got ${getUser.status})`,
|
|
868
|
+
);
|
|
869
|
+
assert(
|
|
870
|
+
(getUser.body as { email: string }).email === crudUserEmail,
|
|
871
|
+
"…and it's the right one",
|
|
872
|
+
);
|
|
429
873
|
|
|
430
874
|
const updateUser = await call("PATCH", `/auth/admin/users/${crudUserId}`, {
|
|
431
875
|
token: await admin.freshToken(),
|
|
432
876
|
workspaceId: admin.workspaceId,
|
|
433
877
|
body: { displayName: "CRUD Test User" },
|
|
434
878
|
});
|
|
435
|
-
assert(
|
|
436
|
-
|
|
879
|
+
assert(
|
|
880
|
+
updateUser.status === 200,
|
|
881
|
+
`admin can edit a user's profile (got ${updateUser.status})`,
|
|
882
|
+
);
|
|
883
|
+
assert(
|
|
884
|
+
(updateUser.body as { displayName: string | null }).displayName ===
|
|
885
|
+
"CRUD Test User",
|
|
886
|
+
"…and the edit is reflected in the response",
|
|
887
|
+
);
|
|
437
888
|
|
|
438
889
|
const crudRoleSlug = `crud-role-${RUN_ID}`;
|
|
439
|
-
const createCrudRole = await call("POST", "/auth/admin/roles", {
|
|
440
|
-
|
|
890
|
+
const createCrudRole = await call("POST", "/auth/admin/roles", {
|
|
891
|
+
token: await admin.freshToken(),
|
|
892
|
+
workspaceId: admin.workspaceId,
|
|
893
|
+
body: { slug: crudRoleSlug },
|
|
894
|
+
});
|
|
895
|
+
assert(
|
|
896
|
+
createCrudRole.status === 201,
|
|
897
|
+
`admin can create a disposable role for the delete test (got ${createCrudRole.status})`,
|
|
898
|
+
);
|
|
441
899
|
const crudRoleId = (createCrudRole.body as { id: string }).id;
|
|
442
900
|
|
|
443
901
|
const updateRole = await call("PATCH", `/auth/admin/roles/${crudRoleId}`, {
|
|
@@ -445,150 +903,334 @@ async function main() {
|
|
|
445
903
|
workspaceId: admin.workspaceId,
|
|
446
904
|
body: { displayName: "CRUD Test Role" },
|
|
447
905
|
});
|
|
448
|
-
assert(
|
|
449
|
-
|
|
906
|
+
assert(
|
|
907
|
+
updateRole.status === 200,
|
|
908
|
+
`admin can edit a role (got ${updateRole.status})`,
|
|
909
|
+
);
|
|
910
|
+
assert(
|
|
911
|
+
(updateRole.body as { displayName: string }).displayName ===
|
|
912
|
+
"CRUD Test Role",
|
|
913
|
+
"…and the edit is reflected in the response",
|
|
914
|
+
);
|
|
450
915
|
|
|
451
|
-
const assignCrudRole = await call(
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
916
|
+
const assignCrudRole = await call(
|
|
917
|
+
"POST",
|
|
918
|
+
`/auth/admin/users/${crudUserId}/roles`,
|
|
919
|
+
{
|
|
920
|
+
token: await admin.freshToken(),
|
|
921
|
+
workspaceId: admin.workspaceId,
|
|
922
|
+
body: { role: crudRoleSlug },
|
|
923
|
+
},
|
|
924
|
+
);
|
|
925
|
+
assert(
|
|
926
|
+
assignCrudRole.status === 201,
|
|
927
|
+
`the disposable role can be assigned before it's deleted (got ${assignCrudRole.status})`,
|
|
928
|
+
);
|
|
457
929
|
|
|
458
930
|
const deleteRole = await call("DELETE", `/auth/admin/roles/${crudRoleId}`, {
|
|
459
931
|
token: await admin.freshToken(),
|
|
460
932
|
workspaceId: admin.workspaceId,
|
|
461
933
|
body: { reason: "prove-cycle cleanup" },
|
|
462
934
|
});
|
|
463
|
-
assert(
|
|
935
|
+
assert(
|
|
936
|
+
deleteRole.status === 200,
|
|
937
|
+
`admin can delete a role (got ${deleteRole.status})`,
|
|
938
|
+
);
|
|
464
939
|
|
|
465
|
-
const rolesAfterDelete = await call("GET", "/auth/admin/roles", {
|
|
940
|
+
const rolesAfterDelete = await call("GET", "/auth/admin/roles", {
|
|
941
|
+
token: await admin.freshToken(),
|
|
942
|
+
workspaceId: admin.workspaceId,
|
|
943
|
+
});
|
|
466
944
|
assert(
|
|
467
|
-
!(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some(
|
|
945
|
+
!(rolesAfterDelete.body as { roles: Array<{ id: string }> }).roles.some(
|
|
946
|
+
(r) => r.id === crudRoleId,
|
|
947
|
+
),
|
|
468
948
|
"a deleted role no longer appears in the role list",
|
|
469
949
|
);
|
|
470
950
|
|
|
471
|
-
const meAfterRoleDelete = await call("GET", "/auth/me", {
|
|
472
|
-
|
|
951
|
+
const meAfterRoleDelete = await call("GET", "/auth/me", {
|
|
952
|
+
token: crudUserTokens.accessToken,
|
|
953
|
+
workspaceId: admin.workspaceId,
|
|
954
|
+
});
|
|
955
|
+
assert(
|
|
956
|
+
!(meAfterRoleDelete.body?.roles ?? []).includes(crudRoleSlug),
|
|
957
|
+
"…and a member who held it no longer resolves it, with no re-login",
|
|
958
|
+
);
|
|
473
959
|
|
|
474
|
-
const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, {
|
|
475
|
-
|
|
960
|
+
const deleteUser = await call("DELETE", `/auth/admin/users/${crudUserId}`, {
|
|
961
|
+
token: await admin.freshToken(),
|
|
962
|
+
workspaceId: admin.workspaceId,
|
|
963
|
+
});
|
|
964
|
+
assert(
|
|
965
|
+
deleteUser.status === 200,
|
|
966
|
+
`admin can delete a user (got ${deleteUser.status})`,
|
|
967
|
+
);
|
|
476
968
|
|
|
477
|
-
const usersAfterDelete = await call("GET", "/auth/admin/users", {
|
|
969
|
+
const usersAfterDelete = await call("GET", "/auth/admin/users", {
|
|
970
|
+
token: await admin.freshToken(),
|
|
971
|
+
workspaceId: admin.workspaceId,
|
|
972
|
+
});
|
|
478
973
|
assert(
|
|
479
|
-
!(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some(
|
|
974
|
+
!(usersAfterDelete.body as { items: Array<{ id: string }> }).items.some(
|
|
975
|
+
(u) => u.id === crudUserId,
|
|
976
|
+
),
|
|
480
977
|
"a deleted user no longer appears in the user list",
|
|
481
978
|
);
|
|
482
979
|
|
|
483
|
-
const getDeletedUser = await call(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
980
|
+
const getDeletedUser = await call(
|
|
981
|
+
"GET",
|
|
982
|
+
`/auth/admin/users/${crudUserId}`,
|
|
983
|
+
{ token: await admin.freshToken(), workspaceId: admin.workspaceId },
|
|
984
|
+
);
|
|
985
|
+
assert(
|
|
986
|
+
getDeletedUser.status === 404,
|
|
987
|
+
`…and fetching them directly by id now 404s (got ${getDeletedUser.status})`,
|
|
988
|
+
);
|
|
488
989
|
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
workspaceId: admin.workspaceId,
|
|
990
|
+
const loginAsDeletedUser = await call("POST", "/auth/login", {
|
|
991
|
+
body: { identifier: crudUserEmail, password: "crud-user-pw-12345" },
|
|
492
992
|
});
|
|
493
|
-
assert(
|
|
993
|
+
assert(
|
|
994
|
+
loginAsDeletedUser.status === 401,
|
|
995
|
+
`…and they can no longer log in (got ${loginAsDeletedUser.status})`,
|
|
996
|
+
);
|
|
494
997
|
|
|
495
|
-
|
|
998
|
+
const selfDeleteAttempt = await call(
|
|
999
|
+
"DELETE",
|
|
1000
|
+
`/auth/admin/users/${(await call("GET", "/auth/me", { token: await admin.freshToken() })).body.sub}`,
|
|
1001
|
+
{
|
|
1002
|
+
token: await admin.freshToken(),
|
|
1003
|
+
workspaceId: admin.workspaceId,
|
|
1004
|
+
},
|
|
1005
|
+
);
|
|
1006
|
+
assert(
|
|
1007
|
+
selfDeleteAttempt.status === 403,
|
|
1008
|
+
`an admin cannot delete their own account (got ${selfDeleteAttempt.status})`,
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
console.log(
|
|
1012
|
+
"11c. login accepts a username or a phone number in place of the email, same password",
|
|
1013
|
+
);
|
|
496
1014
|
const identEmail = uniqueEmail("ident");
|
|
497
1015
|
const identTokens = await signup(identEmail, "ident-user-pw-12345");
|
|
498
|
-
const identUserId = (
|
|
499
|
-
|
|
1016
|
+
const identUserId = (
|
|
1017
|
+
await call("GET", "/auth/me", { token: identTokens.accessToken })
|
|
1018
|
+
).body.sub as string;
|
|
1019
|
+
await hooks.admitUser(ctx, admin, {
|
|
1020
|
+
userId: identUserId,
|
|
1021
|
+
email: identEmail,
|
|
1022
|
+
});
|
|
500
1023
|
|
|
501
1024
|
const identUsername = `ident-user-${RUN_ID}`;
|
|
502
1025
|
const identPhone = `+1-555-${RUN_ID}`;
|
|
503
|
-
const setIdentFields = await call(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1026
|
+
const setIdentFields = await call(
|
|
1027
|
+
"PATCH",
|
|
1028
|
+
`/auth/admin/users/${identUserId}`,
|
|
1029
|
+
{
|
|
1030
|
+
token: await admin.freshToken(),
|
|
1031
|
+
workspaceId: admin.workspaceId,
|
|
1032
|
+
body: { username: identUsername, phone: identPhone },
|
|
1033
|
+
},
|
|
1034
|
+
);
|
|
1035
|
+
assert(
|
|
1036
|
+
setIdentFields.status === 200,
|
|
1037
|
+
`admin can set a user's username and phone (got ${setIdentFields.status})`,
|
|
1038
|
+
);
|
|
509
1039
|
|
|
510
|
-
const loginByUsername = await call("POST", "/auth/login", {
|
|
511
|
-
|
|
512
|
-
|
|
1040
|
+
const loginByUsername = await call("POST", "/auth/login", {
|
|
1041
|
+
body: { identifier: identUsername, password: "ident-user-pw-12345" },
|
|
1042
|
+
});
|
|
1043
|
+
assert(
|
|
1044
|
+
loginByUsername.status === 201 || loginByUsername.status === 200,
|
|
1045
|
+
`login by username succeeds (got ${loginByUsername.status})`,
|
|
1046
|
+
);
|
|
1047
|
+
assert(
|
|
1048
|
+
(loginByUsername.body as AuthTokens).accessToken !== undefined,
|
|
1049
|
+
"…and returns a real access token",
|
|
1050
|
+
);
|
|
513
1051
|
|
|
514
|
-
const loginByPhone = await call("POST", "/auth/login", {
|
|
515
|
-
|
|
1052
|
+
const loginByPhone = await call("POST", "/auth/login", {
|
|
1053
|
+
body: { identifier: identPhone, password: "ident-user-pw-12345" },
|
|
1054
|
+
});
|
|
1055
|
+
assert(
|
|
1056
|
+
loginByPhone.status === 201 || loginByPhone.status === 200,
|
|
1057
|
+
`login by phone succeeds (got ${loginByPhone.status})`,
|
|
1058
|
+
);
|
|
516
1059
|
|
|
517
|
-
const loginByEmailStill = await call("POST", "/auth/login", {
|
|
518
|
-
|
|
1060
|
+
const loginByEmailStill = await call("POST", "/auth/login", {
|
|
1061
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1062
|
+
});
|
|
1063
|
+
assert(
|
|
1064
|
+
loginByEmailStill.status === 201 || loginByEmailStill.status === 200,
|
|
1065
|
+
`login by email still works once username/phone are also set (got ${loginByEmailStill.status})`,
|
|
1066
|
+
);
|
|
519
1067
|
|
|
520
|
-
const loginByUsernameWrongPassword = await call("POST", "/auth/login", {
|
|
521
|
-
|
|
1068
|
+
const loginByUsernameWrongPassword = await call("POST", "/auth/login", {
|
|
1069
|
+
body: { identifier: identUsername, password: "wrong" },
|
|
1070
|
+
});
|
|
1071
|
+
assert(
|
|
1072
|
+
loginByUsernameWrongPassword.status === 401,
|
|
1073
|
+
`login by username with the wrong password is still rejected (got ${loginByUsernameWrongPassword.status})`,
|
|
1074
|
+
);
|
|
522
1075
|
|
|
523
|
-
const loginByUnknownIdentifier = await call("POST", "/auth/login", {
|
|
524
|
-
|
|
1076
|
+
const loginByUnknownIdentifier = await call("POST", "/auth/login", {
|
|
1077
|
+
body: { identifier: `nobody-${RUN_ID}`, password: "whatever" },
|
|
1078
|
+
});
|
|
1079
|
+
assert(
|
|
1080
|
+
loginByUnknownIdentifier.status === 401,
|
|
1081
|
+
`an identifier matching no email/username/phone is rejected the same way (got ${loginByUnknownIdentifier.status})`,
|
|
1082
|
+
);
|
|
525
1083
|
|
|
526
|
-
console.log(
|
|
527
|
-
|
|
1084
|
+
console.log(
|
|
1085
|
+
"11d. change password: wrong current password rejected, right one rotates it and revokes every other session but this one",
|
|
1086
|
+
);
|
|
1087
|
+
const otherDeviceLogin = await call("POST", "/auth/login", {
|
|
1088
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1089
|
+
});
|
|
528
1090
|
const otherDeviceTokens = otherDeviceLogin.body as AuthTokens;
|
|
529
|
-
assert(
|
|
1091
|
+
assert(
|
|
1092
|
+
otherDeviceTokens.sessionId !== identTokens.sessionId,
|
|
1093
|
+
"a second session for the identifier-test user exists, distinct from the signup session",
|
|
1094
|
+
);
|
|
530
1095
|
|
|
531
1096
|
const wrongCurrentPassword = await call("POST", "/auth/password/change", {
|
|
532
1097
|
token: identTokens.accessToken,
|
|
533
|
-
body: {
|
|
1098
|
+
body: {
|
|
1099
|
+
currentPassword: "not-the-real-password",
|
|
1100
|
+
newPassword: "ident-new-pw-67890",
|
|
1101
|
+
},
|
|
534
1102
|
});
|
|
535
|
-
assert(
|
|
1103
|
+
assert(
|
|
1104
|
+
wrongCurrentPassword.status === 401,
|
|
1105
|
+
`changing the password with the wrong current password is rejected (got ${wrongCurrentPassword.status})`,
|
|
1106
|
+
);
|
|
536
1107
|
|
|
537
1108
|
const changePassword = await call("POST", "/auth/password/change", {
|
|
538
1109
|
token: identTokens.accessToken,
|
|
539
|
-
body: {
|
|
1110
|
+
body: {
|
|
1111
|
+
currentPassword: "ident-user-pw-12345",
|
|
1112
|
+
newPassword: "ident-new-pw-67890",
|
|
1113
|
+
},
|
|
540
1114
|
});
|
|
541
|
-
assert(
|
|
1115
|
+
assert(
|
|
1116
|
+
changePassword.status === 201 || changePassword.status === 200,
|
|
1117
|
+
`change-password succeeds with the right current password (got ${changePassword.status})`,
|
|
1118
|
+
);
|
|
542
1119
|
|
|
543
|
-
const oldPasswordNowFails = await call("POST", "/auth/login", {
|
|
544
|
-
|
|
1120
|
+
const oldPasswordNowFails = await call("POST", "/auth/login", {
|
|
1121
|
+
body: { identifier: identEmail, password: "ident-user-pw-12345" },
|
|
1122
|
+
});
|
|
1123
|
+
assert(
|
|
1124
|
+
oldPasswordNowFails.status === 401,
|
|
1125
|
+
`the old password no longer works (got ${oldPasswordNowFails.status})`,
|
|
1126
|
+
);
|
|
545
1127
|
|
|
546
|
-
const newPasswordWorks = await call("POST", "/auth/login", {
|
|
547
|
-
|
|
1128
|
+
const newPasswordWorks = await call("POST", "/auth/login", {
|
|
1129
|
+
body: { identifier: identEmail, password: "ident-new-pw-67890" },
|
|
1130
|
+
});
|
|
1131
|
+
assert(
|
|
1132
|
+
newPasswordWorks.status === 201 || newPasswordWorks.status === 200,
|
|
1133
|
+
`the new password works (got ${newPasswordWorks.status})`,
|
|
1134
|
+
);
|
|
548
1135
|
|
|
549
|
-
const callingSessionSurvives = await call("GET", "/auth/me", {
|
|
550
|
-
|
|
1136
|
+
const callingSessionSurvives = await call("GET", "/auth/me", {
|
|
1137
|
+
token: identTokens.accessToken,
|
|
1138
|
+
});
|
|
1139
|
+
assert(
|
|
1140
|
+
callingSessionSurvives.status === 200,
|
|
1141
|
+
`the session that called change-password is left alone, not logged out by its own request (got ${callingSessionSurvives.status})`,
|
|
1142
|
+
);
|
|
551
1143
|
|
|
552
1144
|
// Revocation lands on the session row (checked at refresh time), not an instant denylist of
|
|
553
1145
|
// the still-live access token — same split `logout()` vs. `logoutOthers()` relies on elsewhere.
|
|
554
|
-
const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", {
|
|
1146
|
+
const otherSessionRefreshRevoked = await call("POST", "/auth/refresh", {
|
|
1147
|
+
body: { refreshToken: otherDeviceTokens.refreshToken },
|
|
1148
|
+
});
|
|
555
1149
|
assert(
|
|
556
1150
|
otherSessionRefreshRevoked.status === 401,
|
|
557
1151
|
`…while the other, unrelated session for the same user can no longer refresh (got ${otherSessionRefreshRevoked.status})`,
|
|
558
1152
|
);
|
|
559
1153
|
|
|
560
|
-
console.log(
|
|
1154
|
+
console.log(
|
|
1155
|
+
"11d-2. self-service profile update: no admin permission required, own row only",
|
|
1156
|
+
);
|
|
561
1157
|
const selfProfileUpdate = await call("PATCH", "/auth/me", {
|
|
562
1158
|
token: identTokens.accessToken,
|
|
563
|
-
body: {
|
|
1159
|
+
body: {
|
|
1160
|
+
displayName: "Self Updated",
|
|
1161
|
+
photo: "data:image/png;base64,AAAA",
|
|
1162
|
+
},
|
|
564
1163
|
});
|
|
565
|
-
assert(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1164
|
+
assert(
|
|
1165
|
+
selfProfileUpdate.status === 200,
|
|
1166
|
+
`a user can update their own profile with no admin permission (got ${selfProfileUpdate.status})`,
|
|
1167
|
+
);
|
|
1168
|
+
const selfProfile = selfProfileUpdate.body as {
|
|
1169
|
+
id: string;
|
|
1170
|
+
displayName: string | null;
|
|
1171
|
+
photo: string | null;
|
|
1172
|
+
};
|
|
1173
|
+
assert(
|
|
1174
|
+
selfProfile.displayName === "Self Updated",
|
|
1175
|
+
"…and the response reflects the new value",
|
|
1176
|
+
);
|
|
1177
|
+
assert(
|
|
1178
|
+
selfProfile.photo === "data:image/png;base64,AAAA",
|
|
1179
|
+
"…including the photo field",
|
|
1180
|
+
);
|
|
569
1181
|
|
|
570
|
-
const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", {
|
|
571
|
-
|
|
1182
|
+
const selfProfileRejectsNoToken = await call("PATCH", "/auth/me", {
|
|
1183
|
+
body: { displayName: "No Auth" },
|
|
1184
|
+
});
|
|
1185
|
+
assert(
|
|
1186
|
+
selfProfileRejectsNoToken.status === 401,
|
|
1187
|
+
`updating a profile without a token is rejected (got ${selfProfileRejectsNoToken.status})`,
|
|
1188
|
+
);
|
|
572
1189
|
|
|
573
|
-
const meAfterProfileUpdate = await call("GET", "/auth/me", {
|
|
1190
|
+
const meAfterProfileUpdate = await call("GET", "/auth/me", {
|
|
1191
|
+
token: identTokens.accessToken,
|
|
1192
|
+
});
|
|
574
1193
|
assert(
|
|
575
|
-
(meAfterProfileUpdate.body as { displayName: string | null })
|
|
1194
|
+
(meAfterProfileUpdate.body as { displayName: string | null })
|
|
1195
|
+
.displayName === "Self Updated",
|
|
576
1196
|
`…and GET /auth/me reflects the same update, no separate refetch path (got ${JSON.stringify(meAfterProfileUpdate.body)})`,
|
|
577
1197
|
);
|
|
578
1198
|
|
|
579
|
-
console.log(
|
|
1199
|
+
console.log(
|
|
1200
|
+
"11e. admin creates a user directly — no signup, usable immediately",
|
|
1201
|
+
);
|
|
580
1202
|
const createdEmail = uniqueEmail("admin-created");
|
|
581
1203
|
const createUser = await call("POST", "/auth/admin/users", {
|
|
582
1204
|
token: await admin.freshToken(),
|
|
583
1205
|
workspaceId: admin.workspaceId,
|
|
584
|
-
body: {
|
|
1206
|
+
body: {
|
|
1207
|
+
email: createdEmail,
|
|
1208
|
+
password: "admin-created-pw-12345",
|
|
1209
|
+
displayName: "Admin Created",
|
|
1210
|
+
},
|
|
585
1211
|
});
|
|
586
|
-
assert(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1212
|
+
assert(
|
|
1213
|
+
createUser.status === 201 || createUser.status === 200,
|
|
1214
|
+
`admin can create a user directly (got ${createUser.status})`,
|
|
1215
|
+
);
|
|
1216
|
+
const createdUser = createUser.body as {
|
|
1217
|
+
id: string;
|
|
1218
|
+
email: string;
|
|
1219
|
+
displayName: string | null;
|
|
1220
|
+
roles: string[];
|
|
1221
|
+
};
|
|
1222
|
+
assert(
|
|
1223
|
+
createdUser.email === createdEmail,
|
|
1224
|
+
"…and the response is the new user, not something stale",
|
|
1225
|
+
);
|
|
1226
|
+
assert(
|
|
1227
|
+
createdUser.displayName === "Admin Created",
|
|
1228
|
+
"…with the profile fields given at creation time",
|
|
1229
|
+
);
|
|
590
1230
|
|
|
591
|
-
const createdUserCanLogin = await call("POST", "/auth/login", {
|
|
1231
|
+
const createdUserCanLogin = await call("POST", "/auth/login", {
|
|
1232
|
+
body: { identifier: createdEmail, password: "admin-created-pw-12345" },
|
|
1233
|
+
});
|
|
592
1234
|
assert(
|
|
593
1235
|
createdUserCanLogin.status === 201 || createdUserCanLogin.status === 200,
|
|
594
1236
|
`the account is usable immediately, no separate activation step (got ${createdUserCanLogin.status})`,
|
|
@@ -599,20 +1241,32 @@ async function main() {
|
|
|
599
1241
|
workspaceId: admin.workspaceId,
|
|
600
1242
|
body: { email: createdEmail, password: "another-pw-12345" },
|
|
601
1243
|
});
|
|
602
|
-
assert(
|
|
1244
|
+
assert(
|
|
1245
|
+
duplicateCreate.status === 409,
|
|
1246
|
+
`creating a second account with the same email is rejected (got ${duplicateCreate.status})`,
|
|
1247
|
+
);
|
|
603
1248
|
|
|
604
1249
|
const createWithRole = await call("POST", "/auth/admin/users", {
|
|
605
1250
|
token: await admin.freshToken(),
|
|
606
1251
|
workspaceId: admin.workspaceId,
|
|
607
|
-
body: {
|
|
1252
|
+
body: {
|
|
1253
|
+
email: uniqueEmail("admin-created-role"),
|
|
1254
|
+
password: "admin-created-pw-67890",
|
|
1255
|
+
roles: [ROLE_SLUG],
|
|
1256
|
+
},
|
|
608
1257
|
});
|
|
609
|
-
assert(
|
|
1258
|
+
assert(
|
|
1259
|
+
createWithRole.status === 201 || createWithRole.status === 200,
|
|
1260
|
+
`admin can name specific roles at creation time (got ${createWithRole.status})`,
|
|
1261
|
+
);
|
|
610
1262
|
assert(
|
|
611
1263
|
(createWithRole.body as { roles: string[] }).roles.includes(ROLE_SLUG),
|
|
612
1264
|
`…and the named role — not the default — is what the new account actually holds (got ${JSON.stringify((createWithRole.body as { roles: string[] }).roles)})`,
|
|
613
1265
|
);
|
|
614
1266
|
|
|
615
|
-
console.log(
|
|
1267
|
+
console.log(
|
|
1268
|
+
"12. permission enforcement: each admin route is opened by its own permission and by nothing else",
|
|
1269
|
+
);
|
|
616
1270
|
// The point of this section is that permissions — not roles — are the boundary. Every probe
|
|
617
1271
|
// user below holds a real role that carries nothing, so "has a role" can never be mistaken
|
|
618
1272
|
// for "has authority", and the only thing that ever changes between them is which single
|
|
@@ -620,18 +1274,33 @@ async function main() {
|
|
|
620
1274
|
const NO_PERMS_ROLE = `probe-no-perms-${RUN_ID}`;
|
|
621
1275
|
const ASSIGNABLE_ROLE = `probe-assignable-${RUN_ID}`;
|
|
622
1276
|
|
|
623
|
-
const noPermsRole = await call("POST", "/auth/admin/roles", {
|
|
1277
|
+
const noPermsRole = await call("POST", "/auth/admin/roles", {
|
|
1278
|
+
token: await admin.freshToken(),
|
|
1279
|
+
workspaceId: admin.workspaceId,
|
|
1280
|
+
body: { slug: NO_PERMS_ROLE },
|
|
1281
|
+
});
|
|
624
1282
|
const assignableRole = await call("POST", "/auth/admin/roles", {
|
|
625
1283
|
token: await admin.freshToken(),
|
|
626
1284
|
workspaceId: admin.workspaceId,
|
|
627
1285
|
body: { slug: ASSIGNABLE_ROLE },
|
|
628
1286
|
});
|
|
629
|
-
assert(
|
|
1287
|
+
assert(
|
|
1288
|
+
noPermsRole.status === 201 && assignableRole.status === 201,
|
|
1289
|
+
"the probe roles are created (neither carries any permission)",
|
|
1290
|
+
);
|
|
630
1291
|
|
|
631
1292
|
const probeTargetEmail = uniqueEmail("probe-target");
|
|
632
|
-
const probeTargetTokens = await signup(
|
|
633
|
-
|
|
634
|
-
|
|
1293
|
+
const probeTargetTokens = await signup(
|
|
1294
|
+
probeTargetEmail,
|
|
1295
|
+
"probe-target-pw-1",
|
|
1296
|
+
);
|
|
1297
|
+
const probeTargetId = (
|
|
1298
|
+
await call("GET", "/auth/me", { token: probeTargetTokens.accessToken })
|
|
1299
|
+
).body.sub as string;
|
|
1300
|
+
await hooks.admitUser(ctx, admin, {
|
|
1301
|
+
userId: probeTargetId,
|
|
1302
|
+
email: probeTargetEmail,
|
|
1303
|
+
});
|
|
635
1304
|
|
|
636
1305
|
const probesFor = (seq: number) =>
|
|
637
1306
|
adminRouteProbes({
|
|
@@ -646,7 +1315,11 @@ async function main() {
|
|
|
646
1315
|
async function runProbes(token: string, seq: number) {
|
|
647
1316
|
const results: Array<{ probe: RouteProbe; status: number }> = [];
|
|
648
1317
|
for (const probe of probesFor(seq)) {
|
|
649
|
-
const res = await call(probe.method, probe.path, {
|
|
1318
|
+
const res = await call(probe.method, probe.path, {
|
|
1319
|
+
token,
|
|
1320
|
+
workspaceId: admin.workspaceId,
|
|
1321
|
+
body: probe.body,
|
|
1322
|
+
});
|
|
650
1323
|
results.push({ probe, status: res.status });
|
|
651
1324
|
}
|
|
652
1325
|
return results;
|
|
@@ -664,84 +1337,168 @@ async function main() {
|
|
|
664
1337
|
const email = uniqueEmail(label);
|
|
665
1338
|
const password = `${label}-pw-12345`;
|
|
666
1339
|
const tokens = await signup(email, password);
|
|
667
|
-
const userId = (
|
|
1340
|
+
const userId = (
|
|
1341
|
+
await call("GET", "/auth/me", { token: tokens.accessToken })
|
|
1342
|
+
).body.sub as string;
|
|
668
1343
|
await hooks.admitUser(ctx, admin, { userId, email });
|
|
669
|
-
await call("POST", `/auth/admin/users/${userId}/roles`, {
|
|
1344
|
+
await call("POST", `/auth/admin/users/${userId}/roles`, {
|
|
1345
|
+
token: await admin.freshToken(),
|
|
1346
|
+
workspaceId: admin.workspaceId,
|
|
1347
|
+
body: { role: NO_PERMS_ROLE },
|
|
1348
|
+
});
|
|
670
1349
|
for (const permission of grants) {
|
|
671
|
-
await call("POST", `/auth/admin/users/${userId}/permissions`, {
|
|
1350
|
+
await call("POST", `/auth/admin/users/${userId}/permissions`, {
|
|
1351
|
+
token: await admin.freshToken(),
|
|
1352
|
+
workspaceId: admin.workspaceId,
|
|
1353
|
+
body: { permission },
|
|
1354
|
+
});
|
|
672
1355
|
}
|
|
673
1356
|
// No re-login: authorization is read from the database on every request in both variants,
|
|
674
1357
|
// so the token this user already holds sees the grants immediately.
|
|
675
1358
|
return { email, password, userId, token: tokens.accessToken };
|
|
676
1359
|
}
|
|
677
1360
|
|
|
678
|
-
console.log(
|
|
1361
|
+
console.log(
|
|
1362
|
+
"12a. holding a role that carries no permissions opens nothing",
|
|
1363
|
+
);
|
|
679
1364
|
const bare = await probeUser("probe-bare", []);
|
|
680
|
-
const bareMe = await call("GET", "/auth/me", {
|
|
681
|
-
|
|
1365
|
+
const bareMe = await call("GET", "/auth/me", {
|
|
1366
|
+
token: bare.token,
|
|
1367
|
+
workspaceId: admin.workspaceId,
|
|
1368
|
+
});
|
|
1369
|
+
assert(
|
|
1370
|
+
bareMe.body?.roles?.includes(NO_PERMS_ROLE),
|
|
1371
|
+
"the probe user really does hold a role — it just carries no permissions",
|
|
1372
|
+
);
|
|
682
1373
|
const bareResults = await runProbes(bare.token, 0);
|
|
683
|
-
assert(
|
|
1374
|
+
assert(
|
|
1375
|
+
bareResults.every((r) => r.status === 403),
|
|
1376
|
+
`all ${bareResults.length} admin routes refuse them (offenders: ${refused(bareResults) || "none"})`,
|
|
1377
|
+
);
|
|
684
1378
|
|
|
685
|
-
console.log(
|
|
1379
|
+
console.log(
|
|
1380
|
+
"12b. one directly-granted permission opens exactly the routes it names, and no others",
|
|
1381
|
+
);
|
|
686
1382
|
const permissions = [...new Set(probesFor(0).map((p) => p.permission))];
|
|
687
1383
|
let probeSeq = 1;
|
|
688
1384
|
/** Kept for 12d: one caller holding exactly one permission is the sharpest case for the equivalence check. */
|
|
689
1385
|
let singleGrantUser: { token: string; permission: string } | undefined;
|
|
690
1386
|
for (const permission of permissions) {
|
|
691
|
-
const holder = await probeUser(
|
|
692
|
-
|
|
1387
|
+
const holder = await probeUser(
|
|
1388
|
+
`probe-${permission.replace(/[^a-z]/g, "")}`,
|
|
1389
|
+
[permission],
|
|
1390
|
+
);
|
|
1391
|
+
if (permission === "users:read")
|
|
1392
|
+
singleGrantUser = { token: holder.token, permission };
|
|
693
1393
|
const results = await runProbes(holder.token, probeSeq++);
|
|
694
|
-
for (const r of results.filter(
|
|
695
|
-
|
|
1394
|
+
for (const r of results.filter(
|
|
1395
|
+
(r) => r.probe.permission === permission,
|
|
1396
|
+
)) {
|
|
1397
|
+
assert(
|
|
1398
|
+
succeeded(r.status),
|
|
1399
|
+
`"${permission}" opens ${r.probe.label} (got ${r.status})`,
|
|
1400
|
+
);
|
|
696
1401
|
}
|
|
697
1402
|
const others = results.filter((r) => r.probe.permission !== permission);
|
|
698
|
-
assert(
|
|
1403
|
+
assert(
|
|
1404
|
+
others.every((r) => r.status === 403),
|
|
1405
|
+
`…and "${permission}" opens nothing else (offenders: ${refused(others) || "none"})`,
|
|
1406
|
+
);
|
|
699
1407
|
}
|
|
700
1408
|
|
|
701
|
-
console.log(
|
|
1409
|
+
console.log(
|
|
1410
|
+
"12c. revoking a permission takes it away again — on the same token, with no re-login",
|
|
1411
|
+
);
|
|
702
1412
|
const revokee = await probeUser("probe-revokee", ["users:read"]);
|
|
703
|
-
const beforeRevoke = await call("GET", "/auth/admin/users", {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const revoked = await call("POST", `/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`, {
|
|
707
|
-
token: await admin.freshToken(),
|
|
1413
|
+
const beforeRevoke = await call("GET", "/auth/admin/users", {
|
|
1414
|
+
token: revokee.token,
|
|
708
1415
|
workspaceId: admin.workspaceId,
|
|
709
1416
|
});
|
|
710
|
-
assert(
|
|
1417
|
+
assert(
|
|
1418
|
+
beforeRevoke.status === 200,
|
|
1419
|
+
`the grant is in effect to begin with (got ${beforeRevoke.status})`,
|
|
1420
|
+
);
|
|
711
1421
|
|
|
712
|
-
const
|
|
713
|
-
|
|
1422
|
+
const revoked = await call(
|
|
1423
|
+
"POST",
|
|
1424
|
+
`/auth/admin/users/${revokee.userId}/permissions/${encodeURIComponent("users:read")}/revoke`,
|
|
1425
|
+
{
|
|
1426
|
+
token: await admin.freshToken(),
|
|
1427
|
+
workspaceId: admin.workspaceId,
|
|
1428
|
+
},
|
|
1429
|
+
);
|
|
1430
|
+
assert(
|
|
1431
|
+
succeeded(revoked.status),
|
|
1432
|
+
`admin can revoke a direct grant (got ${revoked.status})`,
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
const afterRevokeCall = await call("GET", "/auth/admin/users", {
|
|
1436
|
+
token: revokee.token,
|
|
1437
|
+
workspaceId: admin.workspaceId,
|
|
1438
|
+
});
|
|
1439
|
+
assert(
|
|
1440
|
+
afterRevokeCall.status === 403,
|
|
1441
|
+
`and the route closes again on the very next request (got ${afterRevokeCall.status})`,
|
|
1442
|
+
);
|
|
714
1443
|
|
|
715
|
-
console.log(
|
|
1444
|
+
console.log(
|
|
1445
|
+
"12d. front/back equivalence: an ability rebuilt from /auth/me answers exactly as the server enforces",
|
|
1446
|
+
);
|
|
716
1447
|
// This is the property the whole flat-slug model exists for. The client is given permission
|
|
717
1448
|
// slugs and builds its ability with the *same* `defineAbilitiesFor` the server's guard used.
|
|
718
1449
|
// Comparing predicted allow/deny against the real HTTP status for every gated route is what
|
|
719
1450
|
// makes "the console cannot offer a button the API refuses" a fact rather than a hope.
|
|
720
1451
|
async function proveEquivalence(label: string, token: string) {
|
|
721
|
-
const me = await call("GET", "/auth/me", {
|
|
722
|
-
|
|
1452
|
+
const me = await call("GET", "/auth/me", {
|
|
1453
|
+
token,
|
|
1454
|
+
workspaceId: admin.workspaceId,
|
|
1455
|
+
});
|
|
1456
|
+
const ability = defineAbilitiesFor(
|
|
1457
|
+
(me.body?.permissions ?? []) as string[],
|
|
1458
|
+
);
|
|
723
1459
|
const results = await runProbes(token, probeSeq++);
|
|
724
1460
|
|
|
725
1461
|
const disagreements = results
|
|
726
|
-
.map((r) => ({
|
|
1462
|
+
.map((r) => ({
|
|
1463
|
+
label: r.probe.label,
|
|
1464
|
+
predicted: ability.can(r.probe.permission, ABILITY_SUBJECT),
|
|
1465
|
+
served: r.status !== 403,
|
|
1466
|
+
}))
|
|
727
1467
|
.filter((r) => r.predicted !== r.served);
|
|
728
1468
|
assert(
|
|
729
1469
|
disagreements.length === 0,
|
|
730
1470
|
`${label}: all ${results.length} gated routes answer exactly as the client-side ability predicts` +
|
|
731
|
-
(disagreements.length
|
|
1471
|
+
(disagreements.length
|
|
1472
|
+
? ` (disagreements: ${disagreements.map((d) => `${d.label} predicted=${d.predicted}`).join(", ")})`
|
|
1473
|
+
: ""),
|
|
1474
|
+
);
|
|
1475
|
+
return new Set(
|
|
1476
|
+
results.filter((r) => r.status !== 403).map((r) => r.probe.label),
|
|
732
1477
|
);
|
|
733
|
-
return new Set(results.filter((r) => r.status !== 403).map((r) => r.probe.label));
|
|
734
1478
|
}
|
|
735
1479
|
|
|
736
|
-
const adminAllows = await proveEquivalence(
|
|
737
|
-
|
|
738
|
-
|
|
1480
|
+
const adminAllows = await proveEquivalence(
|
|
1481
|
+
"admin",
|
|
1482
|
+
await admin.freshToken(),
|
|
1483
|
+
);
|
|
1484
|
+
const bareAllows = await proveEquivalence(
|
|
1485
|
+
"a member holding a role with no permissions",
|
|
1486
|
+
bare.token,
|
|
1487
|
+
);
|
|
1488
|
+
const grantAllows = await proveEquivalence(
|
|
1489
|
+
`a user holding only "${singleGrantUser!.permission}"`,
|
|
1490
|
+
singleGrantUser!.token,
|
|
1491
|
+
);
|
|
739
1492
|
assert(
|
|
740
|
-
adminAllows.size > grantAllows.size &&
|
|
1493
|
+
adminAllows.size > grantAllows.size &&
|
|
1494
|
+
grantAllows.size > bareAllows.size &&
|
|
1495
|
+
bareAllows.size === 0,
|
|
741
1496
|
`…and the three callers genuinely differ, so the comparison is not vacuous (admin=${adminAllows.size}, single-grant=${grantAllows.size}, bare=${bareAllows.size})`,
|
|
742
1497
|
);
|
|
743
1498
|
|
|
744
|
-
console.log(
|
|
1499
|
+
console.log(
|
|
1500
|
+
"12e. a permission edited in the database changes enforcement, with no code change and no redeploy",
|
|
1501
|
+
);
|
|
745
1502
|
// `isActive: false` is the deny: the row stops appearing in any ability that would have
|
|
746
1503
|
// carried it, without unpicking a single grant. Written here through the admin API — each
|
|
747
1504
|
// variant's hooks prove the same thing again with a raw database write, to rule out the API
|
|
@@ -751,28 +1508,58 @@ async function main() {
|
|
|
751
1508
|
workspaceId: admin.workspaceId,
|
|
752
1509
|
body: { slug: "users:read", isActive: false },
|
|
753
1510
|
});
|
|
754
|
-
assert(
|
|
1511
|
+
assert(
|
|
1512
|
+
succeeded(deactivated.status),
|
|
1513
|
+
`an admin can deactivate a permission (got ${deactivated.status})`,
|
|
1514
|
+
);
|
|
755
1515
|
|
|
756
|
-
const listAfterDeactivate = await call("GET", "/auth/admin/permissions", {
|
|
1516
|
+
const listAfterDeactivate = await call("GET", "/auth/admin/permissions", {
|
|
1517
|
+
token: await admin.freshToken(),
|
|
1518
|
+
workspaceId: admin.workspaceId,
|
|
1519
|
+
});
|
|
757
1520
|
assert(
|
|
758
|
-
(listAfterDeactivate.body?.permissions ?? []).some(
|
|
1521
|
+
(listAfterDeactivate.body?.permissions ?? []).some(
|
|
1522
|
+
(p: { slug: string; isActive: boolean }) =>
|
|
1523
|
+
p.slug === "users:read" && p.isActive === false,
|
|
1524
|
+
),
|
|
759
1525
|
"the catalog reports it as inactive",
|
|
760
1526
|
);
|
|
761
1527
|
|
|
762
|
-
const deniedByEdit = await call("GET", "/auth/admin/users", {
|
|
763
|
-
|
|
1528
|
+
const deniedByEdit = await call("GET", "/auth/admin/users", {
|
|
1529
|
+
token: singleGrantUser!.token,
|
|
1530
|
+
workspaceId: admin.workspaceId,
|
|
1531
|
+
});
|
|
1532
|
+
assert(
|
|
1533
|
+
deniedByEdit.status === 403,
|
|
1534
|
+
`the route it opened is now refused for a user whose grant is untouched (got ${deniedByEdit.status})`,
|
|
1535
|
+
);
|
|
764
1536
|
|
|
765
|
-
const adminDeniedToo = await call("GET", "/auth/admin/users", {
|
|
766
|
-
|
|
1537
|
+
const adminDeniedToo = await call("GET", "/auth/admin/users", {
|
|
1538
|
+
token: await admin.freshToken(),
|
|
1539
|
+
workspaceId: admin.workspaceId,
|
|
1540
|
+
});
|
|
1541
|
+
assert(
|
|
1542
|
+
adminDeniedToo.status === 403,
|
|
1543
|
+
`…including for the administrator, whose role still carries it (got ${adminDeniedToo.status})`,
|
|
1544
|
+
);
|
|
767
1545
|
|
|
768
1546
|
const reactivated = await call("POST", "/auth/admin/permissions", {
|
|
769
1547
|
token: await admin.freshToken(),
|
|
770
1548
|
workspaceId: admin.workspaceId,
|
|
771
1549
|
body: { slug: "users:read", isActive: true },
|
|
772
1550
|
});
|
|
773
|
-
assert(
|
|
774
|
-
|
|
775
|
-
|
|
1551
|
+
assert(
|
|
1552
|
+
succeeded(reactivated.status),
|
|
1553
|
+
`an admin can reactivate it (got ${reactivated.status})`,
|
|
1554
|
+
);
|
|
1555
|
+
const restored = await call("GET", "/auth/admin/users", {
|
|
1556
|
+
token: singleGrantUser!.token,
|
|
1557
|
+
workspaceId: admin.workspaceId,
|
|
1558
|
+
});
|
|
1559
|
+
assert(
|
|
1560
|
+
restored.status === 200,
|
|
1561
|
+
`and every grant that pointed at it works again immediately (got ${restored.status})`,
|
|
1562
|
+
);
|
|
776
1563
|
|
|
777
1564
|
console.log(`13. ${hooks.variant}-specific properties`);
|
|
778
1565
|
await hooks.proveVariantProperties(ctx, admin);
|