Skip to content

Commit 5787df4

Browse files
test(dogfood): pin access-security checklist clauses at the oracle's layer, slice 2 (#26823)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8YSjPf3hTSLEBa52VUBikb4)_ Refs #26571 (slice 2 of about 4, items: access-security.anonymous-deny-surfaces, access-security.public-form-withdrawal-layers, access-security.activation-write-operator-gate, access-security.platform-owner-email-anchor, access-security.record-view-read-audit) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:给 access-security 区接下来 5 个清单条目补上 dogfood 断言(进程内 HTTP 与进程日志,正是这些条款的 api / log oracle 层),共钉住 24 条此前只靠现场手跑的条款;扩写 2 个现有用例文件,新增 3 个隔离用例文件;5 个条目的 `automated.ref` 改写成逐条款对应、`revision` 加一、`history` 记本卡。 - 为什么改:18.2.0 Tier 1 记录 #26468 里这 5 条全是 PARTIAL,其中 3 条此前只有单元级 pin,HTTP 层完全靠手跑;钉住后每次发版不必再重跑。 - 风险与回滚:只加测试和改清单文字,不动任何产品代码;新增断言全部按现行行为写成并跑绿。没有新 CI 通道。回滚即 revert 本 PR。 - 先看:① `platform-owner-email-anchor.dogfood.test.ts`(同一数据库按变量值逐次重启,含 `[authz]` 日志捕获);② `activation-write-operator-gate-single.dogfood.test.ts`(两个开关入口的拒绝原句与 manage_metadata 持有者放行);③ `record-view-read-audit.dogfood.test.ts`(默认不审计与按对象开启两次启动)。 - 两分钟验证:`cd packages/qa/dogfood && pnpm exec vitest run test/platform-owner-email-anchor.dogfood.test.ts`(需先按 dogfood 惯例 build 依赖闭包),预期 `Tests 6 passed`。 - 需要你做的:无,席位落地。 ## What changes Pins at the layer each clause's oracle names: `api` is the in-process HTTP stack of `@objectstack/verify`, and `log` is the process output captured during the boot. Everything is in `packages/qa/dogfood/**`, with no product code changes. Every new assertion was written against today's behaviour and is green. | item | clauses newly pinned | where | |---|---|---| | anonymous-deny-surfaces | acceptance[4], [5], negative[0] | `showcase-anonymous-deny-surfaces` (shared project): the member's `GET /meta` now asserts 200 instead of `.not.toBe(401)`. On the same platform-default boot, an anonymous `GET /forms/contact-us` answers 200 and serves the `showcase_inquiry` form. After the anonymous `DELETE /automation/showcase_reassign_wizard` is refused, the admin reads the flow at its own route and gets 200 | | public-form-withdrawal-layers | acceptance[0] (its live leg), negative[1] | `showcase-public-form-withdrawal-layers`: after the withdrawal save, the test makes no organization active and reads `GET /meta/view/showcase_inquiry.contact`, which serves `allowAnonymous: false` with the link kept; then the organization is made active again. The save message is asserted to name no organization id | | activation-write-operator-gate | acceptance[0]–[3], negative[0]–[2] | New `activation-write-operator-gate-single` (isolated boot). A plain member is refused 403 `PERMISSION_DENIED` at both doors, each with its own exact sentence. An invalid body still gets the 403, and no ledger row is written. A member holding `manage_metadata` through a set authored at runtime, with no `platform_admin` position, is admitted with 200 at both doors. Each of that member's writes lands one row whose key set has no `organization_id`. No refusal names the posture. The admin gets 200 | | platform-owner-email-anchor | acceptance[0]–[3], acceptance[4]'s live leg, negative[0], negative[2] | New `platform-owner-email-anchor`: one database file, restarted once per value in the steps' order, with the datasource admin routes mounted the way `serve` mounts them. The variants are unset, unverified M, a non-matching address, verified M (200 plus the declared envelope), and a malformed list. The malformed list's `[authz]` line is captured once, M is refused while verified, and the admin is served. The test also asserts that `ADMIN_STANDING_NON_TABLE_INPUTS` declares the variable | | record-view-read-audit | acceptance[0]–[4] | New `record-view-read-audit`, two boots. The stock boot runs an option-less `AuditPlugin`: there is no install line (INFO lines are present as a control), and a 200 detail read lands no `read` row after the flush. The configured boot sets `readAudit.objects` to `showcase_account` and `sys_job`. Its install line names `showcase_account` only, and `sys_job` draws the exclusion warning. One detail read lands one row with who, which and when, null values, and `created_at` set to the view instant. A list read and a read of an object that is not opted in land nothing | Ledger: each of the five items gets a rewritten `automated.ref` that maps clauses to cases, `revision` +1, and a `history` row citing #26571. `automated.kind` stays `unit` on the three items that carried it, because this slice's claimed edit surface is `ref`, `revision` and `history`. Their refs now name the dogfood pin first. Clauses left as they are, each with its reason in the item's `automated.ref`: - anonymous-deny-surfaces acceptance[6]: procedural (one verdict per variant in the run record). - public-form-withdrawal-layers acceptance[2]–[5]: no stock fixture (knownGaps), so they stay test-scored. negative[3]–[5] are procedural. - activation-write-operator-gate acceptance[4]: its live leg needs a walled boot (knownGaps[0]), so it is scored from the pin. acceptance[5] is test-scored. negative[3] is procedural. - platform-owner-email-anchor acceptance[5] and negative[1] are test-scored. negative[3] is out of scope. - record-view-read-audit acceptance[5] is test-scored. negative[0] needs a broken ledger. negative[1] is the unit pin's no-principal skip. negative[2] is a cross-reference. ## Tests Head `f3aa3510`, the five touched files, under `os-verify-lock`: `Test Files 5 passed (5)`, `Tests 103 passed (103)` (`VERDICT command-exit 0`). `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The full dogfood battery is CI's. ## Acceptance notes - platform-owner-email-anchor: the variants restart one deployment on one database, because the item's steps restart one deployment too. Measured on this head, a fresh database booted with a well-formed `OS_PLATFORM_OWNER_EMAIL` promotes only the declared owner (`bootstrap-platform-admin.ts`, the declared-owner branch). The seeded admin then answers 403 on `GET /datasources`, so the admin contrast in acceptance[2] exists only on a deployment first booted unset. The pin follows the steps, and the ref records why. - M's verification is a system-context engine update of `email_verified`. This is the doorway the item's knownGaps names, because no HTTP door verifies a second account. Before the malformed variant runs, the pin asserts that M holds no `admin_full_access` grant, so M's refusal there comes from the malformed entry alone. - record-view-read-audit: the configured half uses `bootShowcase`'s `extraPlugins` instead of the knownGaps scratch-copy recipe. It is the same constructor call the docs snippet puts in an app's `plugins` array, and the measured install line is the one the item quotes. ## Verification Local gate subset on head `f3aa3510` (origin/main `c8583f80` contained): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 55 families. All 55 ran and exited 0, and `--ran` reconciles `55 derived famil(ies) accounted for — 55 run, 0 NOT-MEASURED`. Repository-wide scans (`pnpm lint`, the full dogfood battery) are CI's. `node scripts/check-platform-checklist.mjs`: `check-platform-checklist: OK — 16 areas, 329 items (324 active, 1 planned)`. Changeset: none. `@objectstack/dogfood` is private and `docs/qa/**` ships in no package, so this PR carries `skip-changeset`. Template note: `.github/PULL_REQUEST_TEMPLATE.md`'s `Why` section is folded into 维护者速读 and What changes. Its Human Minimum items: 1 n/a (no spec); 2 yes; 3 no formatter ran, and `pnpm lint` is left to CI; 4 yes; 5 n/a (publishes nothing); 6 n/a; 7 yes; 8 yes. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWKBLcQCG7FBS17aMA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dabe771 commit 5787df4

6 files changed

Lines changed: 662 additions & 11 deletions

‎docs/qa/platform-checklist/areas/access-security.json‎

Lines changed: 40 additions & 10 deletions
Large diffs are not rendered by default.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.activation-write-operator-gate`, acceptance[0] to
4+
// acceptance[3] and negative[0] to negative[2], over HTTP on the stock showcase.
5+
//
6+
// Two activation doors switch a packaged artifact off: the flow door
7+
// (`POST /automation/:name/toggle`) and the action door
8+
// (`POST /actions/_activation/:object/:action`). On the stock `single` posture
9+
// two gates sit in front of them, and only one of them can answer:
10+
//
11+
// - the AUTHORING TIER: the caller must hold the `manage_metadata`
12+
// capability. The flow door speaks its own sentence
13+
// (`FLOW_ENABLEMENT_DENY_MESSAGE`, domains/automation.ts); the action door
14+
// speaks the shared `refuseUngrantedActivationAuthoring` sentence
15+
// (domains/activation-gate.ts), with its "functionally equivalent to
16+
// deleting it" rationale and the subject "a packaged action";
17+
// - the §5 OPERATOR gate (`refuseUngrantedActivationWrite`), which is INERT
18+
// on `single`: `postureEnforcesWall('single')` is false, so it returns
19+
// before it reads a position.
20+
//
21+
// The unit pins in packages/runtime/src/domains drive both gates over fakes;
22+
// this file is the HTTP layer the item's `api` clauses name:
23+
//
24+
// - a plain member is refused at both doors with each door's own sentence,
25+
// an invalid body included (the gate runs before body validation), and no
26+
// ledger row is written;
27+
// - a member holding `manage_metadata` through a runtime-authored set, and
28+
// NOT holding the `platform_admin` position, is admitted at both doors:
29+
// the operator gate is inert, not satisfied. Each write lands one
30+
// deployment-level ledger row whose key set has no `organization_id`;
31+
// - the admin is admitted at both doors (the refusals are the capability's,
32+
// not a broken route).
33+
//
34+
// Isolated boot: the switches move global activation state, and the scratch
35+
// set is authored through `/meta`.
36+
37+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
38+
import { type VerifyStack } from '@objectstack/verify';
39+
import { bootShowcase } from './showcase-boot.js';
40+
41+
const SYS = { isSystem: true } as const;
42+
const LEDGER = 'sys_metadata_activation';
43+
const FLOW = 'showcase_urgent_task_alert';
44+
const ACTION = 'showcase_mark_done';
45+
const FLOW_DOOR = `/automation/${FLOW}/toggle`;
46+
const ACTION_DOOR = `/actions/_activation/showcase_task/${ACTION}`;
47+
const SCRATCH_SET = 'activation_gate_meta_author';
48+
const PLAIN = 'activation-gate-plain@verify.test';
49+
const AUTHOR = 'activation-gate-author@verify.test';
50+
51+
const FLOW_SENTENCE = 'Enabling or disabling an automation flow requires the `manage_metadata` capability.';
52+
const ACTION_SENTENCE =
53+
'Enabling or disabling a packaged action requires the `manage_metadata` capability — ' +
54+
'switching a shipped artifact off is functionally equivalent to deleting it for as long as it stays off, ' +
55+
"and the switch is not scoped to the caller's organization.";
56+
/** The §5 operator gate's sentence names the posture; on `single` it must never be heard. */
57+
const POSTURE_SENTENCE = /tenancy posture/;
58+
59+
const DOORS = [
60+
{ door: 'the flow door', path: FLOW_DOOR, type: 'flow', name: FLOW, sentence: FLOW_SENTENCE },
61+
{ door: 'the action door', path: ACTION_DOOR, type: 'action', name: ACTION, sentence: ACTION_SENTENCE },
62+
] as const;
63+
64+
const errorOf = (body: any) => {
65+
const e = body?.error;
66+
return {
67+
code: (e && typeof e === 'object' ? e.code : undefined) ?? body?.code,
68+
message: (e && typeof e === 'object' ? e.message : typeof e === 'string' ? e : undefined) ?? body?.message,
69+
};
70+
};
71+
72+
describe('activation doors on the stock `single` posture: the authoring tier answers, the operator gate is inert', () => {
73+
let stack: VerifyStack;
74+
let ql: any;
75+
let admin: string;
76+
const tok: Record<string, string> = {};
77+
78+
/** The ledger rows for the two artifacts, read through the REST data door as the admin. */
79+
const ledgerRows = async () => {
80+
const where = encodeURIComponent(JSON.stringify({ name: { $in: [FLOW, ACTION] } }));
81+
const r = await stack.apiAs(admin, 'GET', `/data/${LEDGER}?$filter=${where}`);
82+
expect(r.status, 'the ledger is readable').toBe(200);
83+
const body = (await r.json()) as any;
84+
return (body?.records ?? body?.data ?? []) as Array<Record<string, unknown>>;
85+
};
86+
87+
const refused = async (token: string, path: string, body: unknown) => {
88+
const r = await stack.apiAs(token, 'POST', path, body);
89+
return { status: r.status, ...errorOf(await r.json()) };
90+
};
91+
92+
beforeAll(async () => {
93+
stack = await bootShowcase();
94+
admin = await stack.signIn();
95+
ql = await stack.kernel.getServiceAsync('objectql');
96+
97+
// The scratch set: `manage_metadata` and nothing else, authored at runtime
98+
// the way an administrator authors one (no stock showcase set grants it).
99+
const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${SCRATCH_SET}`, {
100+
name: SCRATCH_SET, label: 'Activation gate meta author', objects: {}, systemPermissions: ['manage_metadata'],
101+
});
102+
expect(saved.status, await saved.clone().text()).toBe(200);
103+
104+
tok[PLAIN] = await stack.signUp(PLAIN);
105+
tok[AUTHOR] = await stack.signUp(AUTHOR);
106+
const authorId = (await ql.findOne('sys_user', { where: { email: AUTHOR }, context: SYS }))?.id;
107+
expect(authorId, 'the author persona is provisioned').toBeTruthy();
108+
await ql.insert('sys_user_permission_set', { user_id: authorId, permission_set: SCRATCH_SET }, { context: SYS });
109+
}, 120_000);
110+
111+
afterAll(async () => {
112+
await stack?.stop();
113+
});
114+
115+
// ── the plain member: the authoring tier refuses (acceptance[0], [1], [2]) ──
116+
117+
for (const d of DOORS) {
118+
it(`${d.door}: a plain member is refused 403 PERMISSION_DENIED with that door's own sentence`, async () => {
119+
const r = await refused(tok[PLAIN], d.path, { enabled: false });
120+
expect(r.status, r.message).toBe(403);
121+
expect(r.code).toBe('PERMISSION_DENIED');
122+
expect(r.message, 'the exact tier sentence of this door').toBe(d.sentence);
123+
// negative[0]: on `single` the §5 operator gate never answers.
124+
expect(r.message, 'no posture sentence on the stock posture').not.toMatch(POSTURE_SENTENCE);
125+
});
126+
127+
it(`${d.door}: an INVALID body from the plain member is still the 403, never a 400`, async () => {
128+
const r = await refused(tok[PLAIN], d.path, { enabled: 'false' });
129+
expect(r.status, 'the gate runs before body validation').toBe(403);
130+
expect(r.code).toBe('PERMISSION_DENIED');
131+
expect(r.message).toBe(d.sentence);
132+
});
133+
}
134+
135+
it('negative[2]: the two doors speak distinct sentences', () => {
136+
expect(FLOW_SENTENCE).not.toBe(ACTION_SENTENCE);
137+
expect(ACTION_SENTENCE).toContain('a packaged action');
138+
expect(FLOW_SENTENCE).not.toContain('functionally equivalent');
139+
});
140+
141+
it('acceptance[2]: the refusals wrote no ledger row for either artifact', async () => {
142+
expect(await ledgerRows()).toEqual([]);
143+
});
144+
145+
// ── the scratch-set member: the operator gate is inert (acceptance[3]) ──
146+
147+
it('the scratch-set member holds `manage_metadata` and NOT the platform_admin position (server-side)', async () => {
148+
const session = (await (await stack.apiAs(tok[AUTHOR], 'GET', '/auth/get-session')).json()) as any;
149+
expect(session.user?.positions ?? [], 'the session carries no platform_admin position').not.toContain('platform_admin');
150+
expect(session.user?.isPlatformAdmin, 'and the session is not the platform admin').not.toBe(true);
151+
const r = await stack.apiAs(tok[AUTHOR], 'GET', '/auth/me/permissions');
152+
expect(r.status).toBe(200);
153+
const perms = (await r.json()) as any;
154+
const resolved = perms?.data ?? perms;
155+
expect(resolved.systemPermissions, 'the scratch set resolves').toContain('manage_metadata');
156+
expect(resolved.positions ?? [], 'the resolved positions carry no platform_admin').not.toContain('platform_admin');
157+
});
158+
159+
for (const d of DOORS) {
160+
it(`${d.door}: the scratch-set member is admitted, and one deployment-level row lands with no organization_id key`, async () => {
161+
const off = await stack.apiAs(tok[AUTHOR], 'POST', d.path, { enabled: false });
162+
expect(off.status, await off.clone().text()).toBe(200);
163+
164+
const rows = (await ledgerRows()).filter((row) => row.metadata_type === d.type && row.name === d.name);
165+
expect(rows, `one ${d.type} row`).toHaveLength(1);
166+
const [row] = rows;
167+
expect(row.active === false || row.active === 0, 'switched off').toBe(true);
168+
// Read the KEY SET: a value read of an absent column is null and measures nothing.
169+
expect(Object.keys(row), 'the ledger carries no tenant column').not.toContain('organization_id');
170+
const stored = await ql.find(LEDGER, { where: { metadata_type: d.type, name: d.name }, context: SYS });
171+
expect(stored, 'and the store holds that one row').toHaveLength(1);
172+
expect(Object.keys(stored[0])).not.toContain('organization_id');
173+
174+
const on = await stack.apiAs(tok[AUTHOR], 'POST', d.path, { enabled: true });
175+
expect(on.status, await on.clone().text()).toBe(200);
176+
const [restored] = (await ledgerRows()).filter((x) => x.metadata_type === d.type && x.name === d.name);
177+
expect(restored.active === true || restored.active === 1, 're-enabled').toBe(true);
178+
}, 30_000);
179+
}
180+
181+
// ── the admin: the refused calls are the capability's, not a broken route (negative[1]) ──
182+
183+
for (const d of DOORS) {
184+
it(`${d.door}: the admin's same call answers 200`, async () => {
185+
const off = await stack.apiAs(admin, 'POST', d.path, { enabled: false });
186+
expect(off.status, await off.clone().text()).toBe(200);
187+
const on = await stack.apiAs(admin, 'POST', d.path, { enabled: true });
188+
expect(on.status, await on.clone().text()).toBe(200);
189+
}, 30_000);
190+
}
191+
});
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.platform-owner-email-anchor`, acceptance[0] to
4+
// acceptance[3], the live leg of acceptance[4], and negative[0]'s declaration,
5+
// over HTTP on the stock `single` showcase.
6+
//
7+
// `OS_PLATFORM_OWNER_EMAIL` names the deployment's configured platform
8+
// administrators (packages/core/src/security/platform-admin.ts). It confers
9+
// standing only on a caller whose OWN stored `sys_user` row carries a listed
10+
// address AND reads verified; a malformed entry refuses the whole variable,
11+
// once, loudly on the process log. The unit pins
12+
// (`resolve-authz-context.platform-admin-config.test.ts`, `platform-admin.test.ts`)
13+
// run that derivation in-process; this file drives the same verdicts through
14+
// the mounted server.
15+
//
16+
// The item's steps RESTART one deployment per value, and so does this file:
17+
// one database file, one boot per variant, in the steps' order. The order is
18+
// load-bearing. The first boot runs with the variable unset, so first-user
19+
// promotion anchors the seeded admin on the Default Organization's
20+
// `admin_full_access` grant; a FRESH database booted with a well-formed
21+
// variable promotes only the declared owner (bootstrap-platform-admin.ts), so
22+
// the admin's 200 contrast would not exist there.
23+
//
24+
// Probes, as member M and as the seeded admin:
25+
// - `GET /datasources` (its routes mounted as `serve` mounts them) — the
26+
// `manage_platform_settings` floor answers 403 `PERMISSION_DENIED` before
27+
// any service is resolved, so it discriminates platform-admin standing;
28+
// - `GET /auth/me/permissions` — `systemPermissions` carries
29+
// `manage_platform_settings` only with that standing.
30+
//
31+
// M's `email_verified` is stamped through a system-context engine update: no
32+
// HTTP door verifies a second account (the item's knownGaps), and the seed
33+
// verifies the dev admin through the same doorway. The stamp lives on this
34+
// file's own temporary database.
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { mkdtempSync, rmSync } from 'node:fs';
38+
import { tmpdir } from 'node:os';
39+
import { join } from 'node:path';
40+
import { type VerifyStack } from '@objectstack/verify';
41+
import { ADMIN_STANDING_NON_TABLE_INPUTS, resetPlatformAdminEmailMemo } from '@objectstack/core';
42+
import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec';
43+
import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource';
44+
import { bootShowcase } from './showcase-boot.js';
45+
46+
const ENV = 'OS_PLATFORM_OWNER_EMAIL';
47+
const SYS = { isSystem: true } as const;
48+
const M = 'owner-anchor-m@verify.test';
49+
const M_PASSWORD = 'owner-anchor-m-Passw0rd!';
50+
const NOBODY = 'owner-anchor-nobody@verify.test';
51+
const MALFORMED_ENTRY = 'not an address';
52+
const CAPABILITY = 'manage_platform_settings';
53+
const REFUSED = { datasources: 403, code: 'PERMISSION_DENIED' };
54+
55+
interface Probe { datasources: number; code: unknown; systemPermissions: string[] }
56+
57+
/**
58+
* The verify harness composes the datasource-admin service but not its REST
59+
* routes; `objectstack serve` mounts them from a plugin's `init`, and so does
60+
* this one (the `datasource-meta-door-reaches-admin-door` recipe).
61+
*/
62+
const datasourceRoutes = () => ({
63+
name: 'dogfood.datasource-admin-routes',
64+
version: '1.0.0',
65+
optionalDependencies: ['com.objectstack.server.hono'],
66+
init: async (ctx: { getService?: (name: string) => unknown }) => {
67+
const httpServer = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
68+
registerDatasourceAdminRoutes(httpServer as never, ctx as never, '/api/v1');
69+
},
70+
});
71+
72+
async function probe(stack: VerifyStack, token: string): Promise<Probe> {
73+
const ds = await stack.apiAs(token, 'GET', '/datasources');
74+
const dsBody = (await ds.json().catch(() => ({}))) as any;
75+
const perms = await stack.apiAs(token, 'GET', '/auth/me/permissions');
76+
expect(perms.status, 'me/permissions answers').toBe(200);
77+
const p = (await perms.json()) as any;
78+
return {
79+
datasources: ds.status,
80+
code: dsBody?.error?.code ?? dsBody?.code ?? null,
81+
systemPermissions: ((p?.data ?? p)?.systemPermissions ?? []) as string[],
82+
};
83+
}
84+
85+
describe('OS_PLATFORM_OWNER_EMAIL on the stock `single` showcase: the config anchor over HTTP', () => {
86+
const previous = process.env[ENV];
87+
let dir: string;
88+
let stack: VerifyStack | undefined;
89+
let admin: string;
90+
let m: string;
91+
let ql: any;
92+
93+
/** Restart the deployment with the variable at `value`; the database file is kept. */
94+
const restart = async (value: string | undefined, first = false) => {
95+
await stack?.stop();
96+
stack = undefined;
97+
if (value === undefined) delete process.env[ENV];
98+
else process.env[ENV] = value;
99+
resetPlatformAdminEmailMemo();
100+
stack = await bootShowcase({ databaseFile: join(dir, 'owner-anchor.db'), extraPlugins: [datasourceRoutes()] });
101+
ql = await stack.kernel.getServiceAsync('objectql');
102+
admin = await stack.signIn();
103+
m = first ? await stack.signUp(M, M_PASSWORD) : await stack.signIn(M, M_PASSWORD);
104+
return stack;
105+
};
106+
107+
const mRow = async () => ql.findOne('sys_user', { where: { email: M }, context: SYS });
108+
109+
/** M refused at the floor, and the admin's identical probe served (negative[2]). */
110+
const expectBaseline = async (s: VerifyStack) => {
111+
const mp = await probe(s, m);
112+
expect({ datasources: mp.datasources, code: mp.code }, 'M is refused at the datasource floor').toEqual(REFUSED);
113+
expect(mp.systemPermissions, `M holds no ${CAPABILITY}`).not.toContain(CAPABILITY);
114+
const ap = await probe(s, admin);
115+
expect(ap.datasources, 'the seeded admin\'s identical probe answers 200 (the grant anchor)').toBe(200);
116+
expect(ap.systemPermissions).toContain(CAPABILITY);
117+
};
118+
119+
beforeAll(() => {
120+
dir = mkdtempSync(join(tmpdir(), 'dogfood-owner-anchor-'));
121+
});
122+
123+
afterAll(async () => {
124+
await stack?.stop();
125+
if (previous === undefined) delete process.env[ENV];
126+
else process.env[ENV] = previous;
127+
resetPlatformAdminEmailMemo();
128+
if (dir) rmSync(dir, { recursive: true, force: true });
129+
});
130+
131+
it('acceptance[0]: unset — M is refused, the admin stands on the grant anchor', async () => {
132+
const s = await restart(undefined, true);
133+
expect(Boolean((await mRow())?.email_verified), 'a fresh sign-up reads unverified').toBe(false);
134+
await expectBaseline(s);
135+
}, 120_000);
136+
137+
it('acceptance[1]: the variable names M while M is UNVERIFIED — M is refused exactly as at baseline', async () => {
138+
const s = await restart(M);
139+
expect(Boolean((await mRow())?.email_verified), 'M is still unverified').toBe(false);
140+
await expectBaseline(s);
141+
}, 120_000);
142+
143+
it('acceptance[2]: the variable names an address no row holds — nothing changes for anyone', async () => {
144+
const s = await restart(NOBODY);
145+
expect(await ql.findOne('sys_user', { where: { email: NOBODY }, context: SYS }), 'no row holds it').toBeFalsy();
146+
await expectBaseline(s);
147+
}, 120_000);
148+
149+
it('acceptance[4] live leg: the variable names M and M is VERIFIED — M holds the declared admin envelope', async () => {
150+
const s = await restart(M);
151+
const row = await mRow();
152+
await ql.update('sys_user', { id: row.id, email_verified: true }, { context: SYS });
153+
expect(Boolean((await mRow())?.email_verified), 'M now reads verified').toBe(true);
154+
const mp = await probe(s, m);
155+
expect(mp.datasources, 'M clears the datasource floor').toBe(200);
156+
for (const cap of ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions ?? []) {
157+
expect(mp.systemPermissions, `M's envelope carries ${cap}`).toContain(cap);
158+
}
159+
}, 120_000);
160+
161+
it('acceptance[3]: a malformed list refuses the WHOLE variable, loudly once — the well-formed, VERIFIED sibling confers nothing', async () => {
162+
const lines: string[] = [];
163+
const original = console.error;
164+
console.error = (...args: unknown[]) => {
165+
lines.push(args.map(String).join(' '));
166+
};
167+
try {
168+
const s = await restart(`${M}, ${MALFORMED_ENTRY}`);
169+
const row = await mRow();
170+
expect(Boolean(row?.email_verified), 'M is STILL verified, so the refusal is the malformed entry\'s alone').toBe(true);
171+
// M holds no grant of its own, so a 200 here could only be the config anchor's.
172+
const grants = await ql.find('sys_user_permission_set', { where: { user_id: row.id }, context: SYS });
173+
expect(grants.map((g: any) => g.permission_set), 'M holds no admin_full_access grant').not.toContain('admin_full_access');
174+
await expectBaseline(s);
175+
} finally {
176+
console.error = original;
177+
}
178+
const refusals = lines.filter((l) => l.includes('[authz]') && l.includes(ENV));
179+
expect(refusals, 'the [authz] refusal prints once for the value').toHaveLength(1);
180+
expect(refusals[0], 'it names the offending entry verbatim').toContain(JSON.stringify(` ${MALFORMED_ENTRY}`));
181+
expect(refusals[0]).toContain('WHOLE variable is refused');
182+
expect(refusals[0]).toContain('ZERO config-derived platform administrators');
183+
}, 120_000);
184+
185+
it('negative[0]: the variable is declared a NON-TABLE derivation input — no runtime write reaches the anchor', () => {
186+
expect(ADMIN_STANDING_NON_TABLE_INPUTS).toEqual(
187+
expect.arrayContaining([expect.objectContaining({ kind: 'env', name: ENV })]),
188+
);
189+
});
190+
});

0 commit comments

Comments
 (0)