Skip to content

Commit 6ef7d16

Browse files
committed
test(runtime): pin standalone-stack's app-default resolution against the REAL helper
`standalone-stack.test.ts` hand-copied `appDefaultPermissionSetName` and titled the copy "the exact CLI wiring" — a conformance claim over a duplicate of the rule, green through any change to the real helper. The mirror's stated reason had already expired: `@objectstack/plugin-security` is a plain `dependencies` entry of `@objectstack/runtime`, and another test in the same package already imports from it. Import the real `appSecurityPluginOptions` / `appDefaultPermissionSetName` instead, anchored on `appSecurityPluginOptions` because that is what `serve.ts` calls today (#7001). Also make the fixture discriminate: the non-default set now sits FIRST in the artifact, and the surfaced order is asserted unsorted — the precondition the first-`isDefault` rule depends on, and this package's half of it. Fixes #7092 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
1 parent ba6370c commit 6ef7d16

1 file changed

Lines changed: 52 additions & 28 deletions

File tree

packages/runtime/src/standalone-stack.test.ts

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
// booting from `dist/objectstack.json`, no host `objectstack.config.ts`) must
55
// surface the artifact's app-declared RBAC — `permissions[]` and `positions[]`
66
// — at the top level of the returned stack config. The CLI reads
7-
// `config.permissions` to honour an app-declared default profile (ADR-0056 D7 —
8-
// `appDefaultPermissionSetName` → SecurityPlugin `fallbackPermissionSet`); the
7+
// `config.permissions` to honour an app-declared default profile (ADR-0056 D7 /
8+
// ADR-0090 D5 — `appSecurityPluginOptions(config)` → SecurityPlugin
9+
// `fallbackPermissionSet`, one resolution for every boot path since #7001); the
910
// positions are distributed through `sys_user_position`, never as organization
1011
// roles (ADR-0108). Before this was fixed, `createStandaloneStack`
1112
// surfaced `objects`/`requires`/`manifest` but dropped `permissions`/`roles`, so
@@ -19,6 +20,28 @@ import { tmpdir } from 'node:os';
1920
import { join } from 'node:path';
2021
import { createStandaloneStack } from './standalone-stack.js';
2122
import { createDefaultHostConfig, resolveDefaultArtifactPath } from './default-host.js';
23+
// The REAL resolution, imported — not reproduced. `@objectstack/plugin-security`
24+
// is a plain `dependencies` entry of this package (and another test in this same
25+
// package, src/domains/share-links-enforcement-context.test.ts, already imports
26+
// SecurityPlugin from it), so the "not a runtime dependency" that once justified
27+
// hand-copying the rule here does not hold. #7092: the copy WAS the defect —
28+
// a case titled "the exact CLI wiring" that could only ever prove a duplicate of
29+
// the rule equals itself, and would have stayed green through any change to the
30+
// real helper (last-`isDefault` instead of first, an anchor pre-filter, a shape
31+
// change) while `objectstack dev`/`serve` did something else.
32+
//
33+
// `appSecurityPluginOptions` is the anchor rather than the bare name helper
34+
// because it is what `serve.ts` actually calls today (#7001):
35+
// `new SecurityPlugin(appSecurityPluginOptions(config))`. That construction —
36+
// serve's side of it, and its parity with `bootStack` — is pinned in
37+
// packages/cli/src/commands/serve-verify-security-parity.contract.test.ts; the
38+
// helper's own contract (first-`isDefault` wins, the undefined-vs-`{...undefined}`
39+
// distinction, top-level-only) in
40+
// packages/plugins/plugin-security/src/app-default-permission-set.test.ts. What
41+
// neither of those can see, and what THIS file owns, is the composition: that the
42+
// config `createStandaloneStack` / `createDefaultHostConfig` actually return is a
43+
// config that resolution reads correctly.
44+
import { appDefaultPermissionSetName, appSecurityPluginOptions } from '@objectstack/plugin-security';
2245

2346
// A minimal `objectstack build` artifact carrying an app-declared default
2447
// profile with a hierarchy read scope, an add-on permission set, app roles,
@@ -31,7 +54,16 @@ const ARTIFACT = {
3154
{ name: 'manager', label: 'Manager' },
3255
{ name: 'contributor', label: 'Contributor' },
3356
],
57+
// Declaration order is deliberate and load-bearing (#7092): the NON-default set
58+
// comes FIRST, so the resolution cases below discriminate `isDefault` rather
59+
// than agreeing with "take permissions[0]". With the default set first, a
60+
// resolution that had degenerated to the first entry would have been green.
3461
permissions: [
62+
{
63+
name: 'app_contributor',
64+
label: 'Contributor add-on',
65+
objects: { note: { allowEdit: true } },
66+
},
3567
{
3668
name: 'app_member_default',
3769
label: 'App Member (Default)',
@@ -40,30 +72,9 @@ const ARTIFACT = {
4072
note: { allowRead: true, allowCreate: true, readScope: 'unit_and_below', writeScope: 'unit' },
4173
},
4274
},
43-
{
44-
name: 'app_contributor',
45-
label: 'Contributor add-on',
46-
objects: { note: { allowEdit: true } },
47-
},
4875
],
4976
};
5077

51-
// Mirrors `appDefaultPermissionSetName` from @objectstack/plugin-security (not a
52-
// runtime dependency, so the resolution rule is reproduced here): the first
53-
// first `isDefault` permission set's name (ADR-0090 D5).
54-
function appDefaultPermissionSetName(permissions: unknown): string | undefined {
55-
if (!Array.isArray(permissions)) return undefined;
56-
for (const p of permissions) {
57-
if (p && typeof p === 'object') {
58-
const ps = p as { name?: unknown; isDefault?: unknown };
59-
if (ps.isDefault === true && typeof ps.name === 'string' && ps.name.length > 0) {
60-
return ps.name;
61-
}
62-
}
63-
}
64-
return undefined;
65-
}
66-
6778
// The first createStandaloneStack call cold-loads heavy deps (objectql,
6879
// metadata, driver-memory) via dynamic import — on a cold CI worker that can
6980
// exceed vitest's default 5s test timeout. Do the one-time boot in beforeAll
@@ -83,9 +94,14 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
8394
}, BOOT_TIMEOUT);
8495
afterAll(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } });
8596

86-
it('surfaces permissions[] (with isDefault profile + readScope) at the top level', () => {
97+
it('surfaces permissions[] (with isDefault profile + readScope) at the top level, in DECLARATION order', () => {
8798
expect(Array.isArray(result.permissions)).toBe(true);
88-
expect(result.permissions!.map((p: any) => p.name).sort()).toEqual(['app_contributor', 'app_member_default']);
99+
// Unsorted, on purpose. `appDefaultPermissionSetName` resolves the FIRST
100+
// `isDefault` set, so "which order does the artifact's array arrive in" is a
101+
// precondition of that rule, not a presentation detail — and it is this
102+
// package's half of it. A `.sort()` here erases exactly the property the
103+
// resolution depends on (#7092).
104+
expect(result.permissions!.map((p: any) => p.name)).toEqual(['app_contributor', 'app_member_default']);
89105
const def = result.permissions!.find((p: any) => p.name === 'app_member_default');
90106
expect(def.isDefault).toBe(true);
91107
// the hierarchy read scope must ride through intact — this is what was lost.
@@ -103,10 +119,17 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
103119
expect(result.manifest?.id).toBe('com.test.scope-app');
104120
});
105121

106-
it('the surfaced config drives appDefaultPermissionSetName → the app profile (the exact CLI wiring)', () => {
107-
// Reproduce serve.ts: `config = { ...originalConfig, ...standaloneStack }`,
108-
// then `appDefaultPermissionSetName(config.permissions)` → SecurityPlugin fallback.
122+
it('the surfaced config feeds the REAL appSecurityPluginOptions → the app profile', () => {
123+
// Reproduce serve.ts's merge: `config = { ...originalConfig, ...standaloneStack }`,
124+
// then `new SecurityPlugin(appSecurityPluginOptions(config))`.
109125
const config: any = { ...{}, ...result };
126+
// The whole constructor argument, deep-equalled — the OPTIONS shape is the
127+
// half that was a decision (`name ? { fallbackPermissionSet: name }
128+
// : undefined`), so asserting only the name would leave it unmeasured here.
129+
expect(appSecurityPluginOptions(config)).toEqual({ fallbackPermissionSet: 'app_member_default' });
130+
// …and the name half, read off the surfaced array exactly as the helper does.
131+
// `app_contributor` sits ahead of it in the artifact, so this is a real
132+
// discrimination on `isDefault`, not agreement with permissions[0].
110133
expect(appDefaultPermissionSetName(config.permissions)).toBe('app_member_default');
111134
});
112135

@@ -116,6 +139,7 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005
116139
artifactPath,
117140
databaseUrl: 'memory://standalone-rbac',
118141
});
142+
expect(appSecurityPluginOptions(r)).toEqual({ fallbackPermissionSet: 'app_member_default' });
119143
expect(appDefaultPermissionSetName(r.permissions)).toBe('app_member_default');
120144
expect(r.positions!.map((x: any) => x.name).sort()).toEqual(['contributor', 'manager']);
121145
}, BOOT_TIMEOUT);

0 commit comments

Comments
 (0)