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