create-astram-app 0.3.0 → 0.5.0

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * accessModel — the pure (no React, no I/O) logic behind the service-account access dialog
3
+ * (Phase 4): turn the server's `assignable` set + an existing account into picker options, work out
4
+ * which of the agent's items are LOCKED (assigned by someone with more access), validate, and build
5
+ * a minimal PATCH. Kept separate so it is unit-tested with `node --test` (accessModel.test.mjs).
6
+ *
7
+ * The server is the authority (ADR-0029 ceiling): `assignable` is computed from the same ceiling the
8
+ * write enforces. An agent's existing role/node that is NOT assignable to the editor is shown checked
9
+ * + disabled and is NEVER sent — a non-root PATCH keeps it server-side (sending it would be a 403).
10
+ * Options carry `name` for display; `id` is only the value sent back (never rendered, ADR-0012).
11
+ */
12
+
13
+ export const LOCKED_HINT = 'assigned by someone with more access';
14
+ export const NO_ROLES_WARNING = 'This agent will be denied everything until it has a role.';
15
+ export const SCOPE_REQUIRED = 'Choose at least one scope node.';
16
+
17
+ /** Depth of each node by walking `parentId` inside the set (cycle-safe; unknown parent ⇒ top). */
18
+ export function withDepth(nodes) {
19
+ const byId = new Map(nodes.map((n) => [n.id, n]));
20
+ const depthOf = (n) => {
21
+ let depth = 0;
22
+ const seen = new Set([n.id]);
23
+ let p = n.parentId;
24
+ while (p && byId.has(p) && !seen.has(p)) {
25
+ seen.add(p);
26
+ depth += 1;
27
+ p = byId.get(p).parentId;
28
+ }
29
+ return depth;
30
+ };
31
+ return nodes.map((n) => ({ id: n.id, name: n.name, depth: depthOf(n) }));
32
+ }
33
+
34
+ /** Items the agent holds that the editor cannot grant, labelled from the view (same order as ids). */
35
+ function lockedItems(ids, labels, assignableIds) {
36
+ const out = [];
37
+ (ids || []).forEach((id, i) => {
38
+ if (!assignableIds.has(id))
39
+ out.push({ id, name: (labels && labels[i]) || 'Unnamed', depth: 0, locked: true });
40
+ });
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Build the picker model. `account` is null for create.
46
+ * - create: no roles ticked; every assignable scope node ticked ("defaults to everything you can reach").
47
+ * - edit: the agent's assignable items ticked; its non-assignable items appended as locked.
48
+ */
49
+ export function buildAccessModel(assignable, account) {
50
+ const roles = (assignable && assignable.roles) || [];
51
+ const nodes = (assignable && assignable.scopeNodes) || [];
52
+ const roleIds = new Set(roles.map((r) => r.id));
53
+ const nodeIds = new Set(nodes.map((n) => n.id));
54
+
55
+ const roleOptions = roles.map((r) => ({ id: r.id, name: r.name, depth: 0 }));
56
+ const scopeOptions = withDepth(nodes);
57
+
58
+ if (!account) {
59
+ return {
60
+ roleOptions,
61
+ scopeOptions,
62
+ initialRoles: [],
63
+ initialScope: nodes.map((n) => n.id),
64
+ };
65
+ }
66
+ const lockedRoles = lockedItems(account.roleIds, account.roleLabels, roleIds);
67
+ const lockedScope = lockedItems(account.scopeSubtreeNodeIds, account.scopeLabels, nodeIds);
68
+ return {
69
+ roleOptions: [...roleOptions, ...lockedRoles],
70
+ scopeOptions: [...scopeOptions, ...lockedScope],
71
+ initialRoles: (account.roleIds || []).filter((id) => roleIds.has(id)),
72
+ initialScope: (account.scopeSubtreeNodeIds || []).filter((id) => nodeIds.has(id)),
73
+ };
74
+ }
75
+
76
+ /** Number of locked options (always held by the agent, never sent). */
77
+ export function lockedCount(options) {
78
+ return options.filter((o) => o.locked).length;
79
+ }
80
+
81
+ /** Order-insensitive equality of two id lists. */
82
+ export function sameIds(a, b) {
83
+ const sa = new Set(a);
84
+ const sb = new Set(b);
85
+ if (sa.size !== sb.size) return false;
86
+ for (const x of sa) if (!sb.has(x)) return false;
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Field errors for the form (empty object ⇒ valid). When the scope will be sent (`requireScope`,
92
+ * default true) it needs ≥1 node — the server rejects an empty scope on edit with a 400.
93
+ */
94
+ export function validateAccess({ label, scope, requireScope = true }) {
95
+ const errors = {};
96
+ if (!label || !label.trim()) errors.label = 'Give the service account a name.';
97
+ if (requireScope && (!scope || scope.length === 0)) errors.scope = SCOPE_REQUIRED;
98
+ return errors;
99
+ }
100
+
101
+ /** Does the agent end up with no role at all (ticked + locked)? → show the non-blocking warning. */
102
+ export function hasNoRoles(roles, roleOptions) {
103
+ return roles.length === 0 && lockedCount(roleOptions) === 0;
104
+ }
105
+
106
+ /** Create body: always sends roles + ≥1 scope node (only assignable ids — never locked ones). */
107
+ export function createBody({ label, roles, scope }) {
108
+ return { label: label.trim(), roleIds: [...roles], scopeSubtreeNodeIds: [...scope] };
109
+ }
110
+
111
+ /**
112
+ * Minimal PATCH for an edit: only the fields that changed. `roles` / `scope` hold assignable ids
113
+ * only (locked items are preserved by the server). Returns `{}` when nothing changed.
114
+ */
115
+ export function editPatch({ account, label, roles, scope, initialRoles, initialScope }) {
116
+ const patch = {};
117
+ const nextLabel = label.trim();
118
+ if (nextLabel !== account.label) patch.label = nextLabel;
119
+ if (!sameIds(roles, initialRoles)) patch.roleIds = [...roles];
120
+ if (!sameIds(scope, initialScope)) patch.scopeSubtreeNodeIds = [...scope];
121
+ return patch;
122
+ }
123
+
124
+ /** Route a 400 server message to the field it is about (else the form-level error). */
125
+ export function fieldOfServerError(message) {
126
+ const m = (message || '').toLowerCase();
127
+ if (m.includes('scope')) return 'scope';
128
+ if (m.includes('label') || m.includes('name')) return 'label';
129
+ return null;
130
+ }
131
+
132
+ /** First `max` names + a "+N" overflow count, for the row badges. */
133
+ export function summarizeNames(names, max = 3) {
134
+ const list = names || [];
135
+ return { shown: list.slice(0, max), more: Math.max(0, list.length - max) };
136
+ }
@@ -0,0 +1,161 @@
1
+ // accessModel.test.mjs — Phase 4: the service-account access dialog's pure logic (picker
2
+ // options, locked items, validation, minimal PATCH). No React/DOM; runs under Node's built-in
3
+ // runner with zero dependencies:
4
+ //
5
+ // node --test src/pages/service-accounts/accessModel.test.mjs
6
+ import { test, describe } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import {
9
+ withDepth,
10
+ buildAccessModel,
11
+ lockedCount,
12
+ sameIds,
13
+ validateAccess,
14
+ hasNoRoles,
15
+ createBody,
16
+ editPatch,
17
+ fieldOfServerError,
18
+ summarizeNames,
19
+ SCOPE_REQUIRED,
20
+ } from './accessModel.js';
21
+
22
+ const ASSIGNABLE = {
23
+ roles: [
24
+ { id: 'r-ingest', name: 'Ingest' },
25
+ { id: 'r-audit', name: 'Auditor' },
26
+ ],
27
+ scopeNodes: [
28
+ { id: 'n-acme', name: 'Acme', parentId: null },
29
+ { id: 'n-east', name: 'Acme East', parentId: 'n-acme' },
30
+ { id: 'n-east-1', name: 'East 1', parentId: 'n-east' },
31
+ ],
32
+ };
33
+
34
+ const ACCOUNT = {
35
+ id: 'sa-1',
36
+ label: 'Ingest Bot',
37
+ roleIds: ['r-ingest', 'r-finance'],
38
+ roleLabels: ['Ingest', 'Finance Admin'],
39
+ scopeSubtreeNodeIds: ['n-east', 'n-beta'],
40
+ scopeLabels: ['Acme East', 'Beta'],
41
+ };
42
+
43
+ describe('withDepth', () => {
44
+ test('indents by parent chain inside the set', () => {
45
+ assert.deepEqual(
46
+ withDepth(ASSIGNABLE.scopeNodes).map((n) => n.depth),
47
+ [0, 1, 2],
48
+ );
49
+ });
50
+ test('a parent outside the set, or a cycle, does not loop or over-indent', () => {
51
+ const d = withDepth([
52
+ { id: 'a', name: 'A', parentId: 'b' },
53
+ { id: 'b', name: 'B', parentId: 'a' },
54
+ { id: 'c', name: 'C', parentId: 'zzz' },
55
+ ]);
56
+ assert.equal(d.find((n) => n.id === 'c').depth, 0);
57
+ assert.ok(d.every((n) => n.depth <= 1));
58
+ });
59
+ });
60
+
61
+ describe('buildAccessModel', () => {
62
+ test('create: no roles ticked, every reachable node ticked, nothing locked', () => {
63
+ const m = buildAccessModel(ASSIGNABLE, null);
64
+ assert.deepEqual(m.initialRoles, []);
65
+ assert.deepEqual(m.initialScope, ['n-acme', 'n-east', 'n-east-1']);
66
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
67
+ });
68
+
69
+ test('edit: assignable items ticked; out-of-ceiling items appended LOCKED with their names', () => {
70
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
71
+ assert.deepEqual(m.initialRoles, ['r-ingest']);
72
+ assert.deepEqual(m.initialScope, ['n-east']);
73
+ const lockedRole = m.roleOptions.find((o) => o.locked);
74
+ assert.deepEqual(lockedRole, {
75
+ id: 'r-finance',
76
+ name: 'Finance Admin',
77
+ depth: 0,
78
+ locked: true,
79
+ });
80
+ const lockedNode = m.scopeOptions.find((o) => o.locked);
81
+ assert.equal(lockedNode.name, 'Beta');
82
+ });
83
+
84
+ test('edit by root (everything assignable): nothing locked', () => {
85
+ const all = {
86
+ roles: [...ASSIGNABLE.roles, { id: 'r-finance', name: 'Finance Admin' }],
87
+ scopeNodes: [...ASSIGNABLE.scopeNodes, { id: 'n-beta', name: 'Beta', parentId: null }],
88
+ };
89
+ const m = buildAccessModel(all, ACCOUNT);
90
+ assert.equal(lockedCount(m.roleOptions) + lockedCount(m.scopeOptions), 0);
91
+ assert.deepEqual(m.initialRoles.sort(), ['r-finance', 'r-ingest']);
92
+ });
93
+ });
94
+
95
+ describe('validation + warnings', () => {
96
+ test('a name and at least one scope node are required', () => {
97
+ assert.deepEqual(validateAccess({ label: ' ', scope: [] }), {
98
+ label: 'Give the service account a name.',
99
+ scope: SCOPE_REQUIRED,
100
+ });
101
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: ['n-acme'] }), {});
102
+ });
103
+ test('an unsent (unchanged) scope is not validated — rename of an all-locked agent still works', () => {
104
+ assert.deepEqual(validateAccess({ label: 'Bot', scope: [], requireScope: false }), {});
105
+ });
106
+ test('no-roles warning only when nothing is ticked AND nothing is locked', () => {
107
+ const create = buildAccessModel(ASSIGNABLE, null);
108
+ assert.equal(hasNoRoles([], create.roleOptions), true);
109
+ const edit = buildAccessModel(ASSIGNABLE, ACCOUNT);
110
+ assert.equal(hasNoRoles([], edit.roleOptions), false); // the locked role still applies
111
+ });
112
+ });
113
+
114
+ describe('request bodies never carry locked items', () => {
115
+ test('createBody sends trimmed name + roles + scope', () => {
116
+ assert.deepEqual(createBody({ label: ' Bot ', roles: ['r-ingest'], scope: ['n-acme'] }), {
117
+ label: 'Bot',
118
+ roleIds: ['r-ingest'],
119
+ scopeSubtreeNodeIds: ['n-acme'],
120
+ });
121
+ });
122
+
123
+ test('editPatch: unchanged → {}; only changed fields are sent; locked ids never appear', () => {
124
+ const m = buildAccessModel(ASSIGNABLE, ACCOUNT);
125
+ const base = { account: ACCOUNT, initialRoles: m.initialRoles, initialScope: m.initialScope };
126
+ assert.deepEqual(
127
+ editPatch({ ...base, label: 'Ingest Bot', roles: ['r-ingest'], scope: ['n-east'] }),
128
+ {},
129
+ );
130
+ assert.deepEqual(
131
+ editPatch({ ...base, label: 'Renamed', roles: ['r-ingest'], scope: ['n-east'] }),
132
+ {
133
+ label: 'Renamed',
134
+ },
135
+ );
136
+ const p = editPatch({ ...base, label: 'Ingest Bot', roles: [], scope: ['n-east', 'n-acme'] });
137
+ assert.deepEqual(p, { roleIds: [], scopeSubtreeNodeIds: ['n-east', 'n-acme'] });
138
+ assert.ok(!JSON.stringify(p).includes('r-finance'));
139
+ assert.ok(!JSON.stringify(p).includes('n-beta'));
140
+ });
141
+
142
+ test('sameIds is order-insensitive', () => {
143
+ assert.equal(sameIds(['a', 'b'], ['b', 'a']), true);
144
+ assert.equal(sameIds(['a'], ['a', 'b']), false);
145
+ });
146
+ });
147
+
148
+ describe('misc', () => {
149
+ test('fieldOfServerError routes 400s to the right field', () => {
150
+ assert.equal(fieldOfServerError('choose at least one scope node'), 'scope');
151
+ assert.equal(fieldOfServerError('label cannot be empty'), 'label');
152
+ assert.equal(fieldOfServerError('no fields to update'), null);
153
+ });
154
+ test('summarizeNames: first 3 + overflow count', () => {
155
+ assert.deepEqual(summarizeNames(['a', 'b', 'c', 'd', 'e']), {
156
+ shown: ['a', 'b', 'c'],
157
+ more: 2,
158
+ });
159
+ assert.deepEqual(summarizeNames(undefined), { shown: [], more: 0 });
160
+ });
161
+ });
@@ -16,6 +16,9 @@
16
16
  * via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
17
17
  * `role:read` — "managing/reading grants IS role administration")
18
18
  * - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
19
+ * - Service accounts → `identity:service_account:read`
20
+ * (service-account.controller.ts `GET service-accounts`; create/edit are
21
+ * service_account:create|update, keys are service_account:manage_keys)
19
22
  * - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
20
23
  * cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
21
24
  * all gated `role:read` — reading/managing cross-tenant grants IS role
@@ -24,6 +27,7 @@
24
27
  * caller may reach (no admin-only list route backs them).
25
28
  */
26
29
  import {
30
+ Bot,
27
31
  Building2,
28
32
  Handshake,
29
33
  History,
@@ -74,6 +78,22 @@ export const NAV_SECTIONS = [
74
78
  matchPrefix: true,
75
79
  gate: 'identity:role:read',
76
80
  },
81
+ {
82
+ icon: ShieldCheck,
83
+ label: 'Record policies',
84
+ path: '/record-policies',
85
+ badge: null,
86
+ matchPrefix: true,
87
+ gate: 'identity:role:read',
88
+ },
89
+ {
90
+ icon: Bot,
91
+ label: 'Service accounts',
92
+ path: '/service-accounts',
93
+ badge: null,
94
+ matchPrefix: true,
95
+ gate: 'identity:service_account:read',
96
+ },
77
97
  {
78
98
  icon: Handshake,
79
99
  label: 'Cross-tenant',
@@ -19,11 +19,11 @@
19
19
  "postinstall": "node scripts/sync-astram-guide.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@astram/core": "^1.0.0",
23
- "@astram/engine": "^0.1.3",
24
- "@astram/guard": "^0.5.0",
22
+ "@astram/core": "^1.3.0",
23
+ "@astram/engine": "^0.3.0",
24
+ "@astram/guard": "^0.7.0",
25
25
  "@astram/guide": "^0.1.0",
26
- "@astram/identity": "^0.2.0",
26
+ "@astram/identity": "^0.4.0",
27
27
  "@astram/migrate": "^0.2.0",
28
28
  "@astram/sdk": "^0.2.0",
29
29
  "@nestjs/common": "^11.0.0",
@@ -36,11 +36,12 @@ import { DemoController } from './health/demo.controller';
36
36
  * no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
37
37
  * boot; turning it on requires the config above, by design — fail-closed.)
38
38
  *
39
- * ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD) ──────────
40
- * The framework also ships the identity management surface — company/user/role CRUD
41
- * (memberships, access, audit in later versions) — as controllers you mount by
42
- * importing ONE module. `@astram/guard` never imports `@astram/identity` (the
43
- * dependency graph is identity → guard), so the consumer wires it (ADR-0026):
39
+ * ── OPTIONAL: the identity ADMIN CONSOLE (companies/users/roles CRUD + authz) ───
40
+ * The framework also ships the identity management surface — company/user/role CRUD,
41
+ * memberships, access/grants, audit, cross-tenant grants, the GUIDED RULE-BUILDER
42
+ * (record-access policies), and OWNER SHARES — as controllers you mount by importing
43
+ * ONE module. `@astram/guard` never imports `@astram/identity` (the dependency graph is
44
+ * identity → guard), so the consumer wires it (ADR-0026):
44
45
  * 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
45
46
  * nav lights up), AND pass `recordAuthorization` (so the decision guard actually
46
47
  * GRANTS — without it, decide() denies every classified route):
@@ -51,19 +52,30 @@ import { DemoController } from './health/demo.controller';
51
52
  * ShieldModule.forRoot({ auth: true,
52
53
  * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
53
54
  * recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
54
- * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles}
55
+ * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
55
56
  * ],
56
57
  * `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
57
58
  * (no separate engine setup). Every route is `@Classified` — enforced by the same
58
59
  * deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
59
60
  * from your roles' grants. Omit the module and login still works; the admin pages 404.
61
+ *
62
+ * ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them BOTH to
63
+ * `buildIdentityRecordAuthorization(authzPool, [Note])` (so their action keys project
64
+ * into gate-5 grants) AND to `IdentityFeatureModule.forRoot({ extraObjects: [Note] })`
65
+ * (so the GUIDED RULE-BUILDER schema + the OWNER-SHARE resolver know them — else a
66
+ * record of your type is NOT policy-authorable and NOT shareable: "type '…' is not
67
+ * registered — cannot share"). The frontend ships the matching pages/widget
68
+ * (RecordPoliciesPage, ShareDialog) already wired to `useAuth().recordPolicy` /
69
+ * `.recordShare`.
60
70
  */
61
71
  @Module({
62
72
  // Enforcement-only by default (boots with no config). For real login + the identity
63
- // admin console, use (see the OPTIONAL blocks above for the full form):
73
+ // admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
74
+ // blocks above for the full form + the extraObjects note for your OWN owned types):
64
75
  // imports: [
65
- // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
66
- // IdentityFeatureModule.forRoot(),
76
+ // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
77
+ // recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
78
+ // IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
67
79
  // ],
68
80
  imports: [ShieldModule.forRoot()],
69
81
  controllers: [HealthController, DemoController],
@@ -1,5 +1,13 @@
1
- import type { INestApplication } from '@nestjs/common';
1
+ import { Controller, Get, type INestApplication, type ModuleMetadata } from '@nestjs/common';
2
+ import { DiscoveryModule } from '@nestjs/core';
2
3
  import { Test } from '@nestjs/testing';
4
+ import { checkCompleteness, createModuleRegistry } from '@astram/core';
5
+ import {
6
+ CatalogGatabilityError,
7
+ CatalogReconciler,
8
+ OperationInventoryScanner,
9
+ ShieldModule,
10
+ } from '@astram/guard';
3
11
  import request from 'supertest';
4
12
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
13
  import { AppModule } from '../src/app.module';
@@ -46,11 +54,70 @@ describe('__APP_NAME__ — framework enforcement + health (e2e)', () => {
46
54
  message: expect.stringContaining('public route reachable'),
47
55
  });
48
56
  });
57
+ });
58
+
59
+ /**
60
+ * A deliberately UNCLASSIFIED controller (no @Public(), no @Classified()). It lives
61
+ * only in this test file — never in src/ — because a live unclassified route would
62
+ * abort a real boot (see demo.controller.ts). Used to prove both deny-by-default layers.
63
+ */
64
+ @Controller('probe')
65
+ class UnclassifiedProbeController {
66
+ @Get('unclassified')
67
+ unclassifiedRoute(): { message: string } {
68
+ return { message: 'this body must never be reached — the guard denies first' };
69
+ }
70
+ }
71
+
72
+ /** Reflect every mounted route of `imports`/`controllers` into an OperationInventory. */
73
+ async function scanRoutes(opts: Pick<ModuleMetadata, 'imports' | 'controllers'>) {
74
+ const moduleRef = await Test.createTestingModule({
75
+ imports: [DiscoveryModule, ...(opts.imports ?? [])],
76
+ controllers: opts.controllers ?? [],
77
+ providers: [OperationInventoryScanner],
78
+ }).compile();
79
+ const inventory = moduleRef.get(OperationInventoryScanner).scan('app');
80
+ await moduleRef.close();
81
+ return inventory;
82
+ }
49
83
 
50
- it('GET /demo/unclassified is DENIED (403) — deny-by-default (ADR-0002)', async () => {
51
- const res = await request(app.getHttpServer()).get('/demo/unclassified');
52
- expect(res.status).toBe(403);
53
- // The guard denies BEFORE the handler body runs.
54
- expect(res.body.message).toContain('no access classification');
84
+ describe('__APP_NAME__ — deny-by-default (ADR-0002)', () => {
85
+ it('BOOT layer: an unclassified route ABORTS boot via verifyGatable (fail-closed)', async () => {
86
+ const inventory = await scanRoutes({ controllers: [UnclassifiedProbeController] });
87
+ // The same check the identity boot provider runs: a forgotten declaration means the
88
+ // app refuses to start, not that the route silently serves traffic.
89
+ expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
90
+ CatalogGatabilityError,
91
+ );
92
+ expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
93
+ /UnclassifiedProbeController#unclassifiedRoute/,
94
+ );
95
+ });
96
+
97
+ it('BOOT layer: every route in YOUR AppModule is @Public() or @Classified() (none stranded)', async () => {
98
+ // Fails the moment a route is added without a classification — the same routes the
99
+ // boot check would strand. (Registering @Classified keys is the identity module's job,
100
+ // so only the "forgotten declaration" half is asserted here.)
101
+ const inventory = await scanRoutes({ imports: [AppModule] });
102
+ expect(inventory.operations.length).toBeGreaterThan(0);
103
+ const { strandedOperations } = checkCompleteness(inventory, createModuleRegistry());
104
+ expect(strandedOperations.map((o) => o.operationId)).toEqual([]);
105
+ });
106
+
107
+ it('REQUEST layer: an unclassified route is DENIED (403) before its handler runs', async () => {
108
+ const moduleRef = await Test.createTestingModule({
109
+ imports: [ShieldModule.forRoot()],
110
+ controllers: [UnclassifiedProbeController],
111
+ }).compile();
112
+ const probeApp = moduleRef.createNestApplication();
113
+ await probeApp.init();
114
+ try {
115
+ const res = await request(probeApp.getHttpServer()).get('/probe/unclassified');
116
+ expect(res.status).toBe(403);
117
+ // The guard denies BEFORE the handler body runs.
118
+ expect(res.body.message).toContain('no access classification');
119
+ } finally {
120
+ await probeApp.close();
121
+ }
55
122
  });
56
123
  });