Skip to content

Commit 9f40488

Browse files
test(dogfood): pin access-security checklist clauses at the oracle's layer, slice 3 (#26905)
<!-- 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 3 of 4, items: access-security.anonymous-floor-v18-doors, access-security.meta-authoring-trail-and-audience-gates, access-security.security-catalog-activation-and-clone-doors, access-security.security-catalog-one-holder-and-boot-report, access-security.grant-table-write-contract) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:给 access-security 区第三批 5 个清单条目补上 dogfood 断言(进程内 HTTP 与启动日志,即这些条款 oracle 指定的层),共钉住 14 条此前只在单元层或靠手跑的条款;扩写 4 个现有用例文件,新增 4 个隔离用例文件。8 个条目的清单记录更新:本批 5 个的 `automated.ref` 逐条款改写,另外按席位要求把第二批 3 个条目(以及本批 one-holder 条目)的 `automated.kind` 从 `unit` 改成 `dogfood`;每个条目 `revision` 加一、`history` 记本卡。 - 为什么改:18.2.0 Tier 1 记录 #26468 把这 5 条判为 PARTIAL:错误码、管理员对照、备用读入口和启动日志都只在单元层或手跑。 - 风险与回滚:只加测试和改清单文字,不动产品代码,不加 CI 通道。回滚即 revert 本 PR。 - 先看:① `meta-view-audience-alternate-doors.dogfood.test.ts`(受限视图在 /published、/layers、?state=draft、/history 各入口的拒绝,以及 ETag 回放不得 304);② `security-catalog-one-holder-http.dogfood.test.ts`(install-local 重名拒绝与启动日志 50 条上限);③ `access-security.json` 的 kind 改动。 - 两分钟验证:`cd packages/qa/dogfood && pnpm exec vitest run test/meta-view-audience-alternate-doors.dogfood.test.ts`(需先按 dogfood 惯例 build 依赖闭包),预期 `Tests 8 passed`。 - 需要你做的:无,席位落地。余下 3 条(parent-and-organization-write-guards、deployment-tables-platform-only、share-link-combined-gates)在第 4 片。 ## What changes Each clause is pinned at the layer its oracle names. `api` is the in-process HTTP stack of `@objectstack/verify`, and `log` is the process output captured across a boot. All changes are 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-floor-v18-doors | acceptance[1], [2] (admin leg), [3], [4] | New `anonymous-floor-v18-doors` (isolated). Each of steps[0]'s six doors answers an anonymous caller 401 with `UNAUTHENTICATED` or `AUTH_REQUIRED`. The admin's same GET is served 200 on every door except `/keys`, which serves POST alone and answers the admin's GET 405. A bogus share-link token gets a 4xx that is not 401. An anonymous form submit that forges `owner_id` and `status` answers 201 with exactly `{ id }`, and neither forged value is stored. `showcase-anonymous-deny-surfaces` gains the admin leg of the retired `/docs` comparison, which is read-only and stays in the shared project | | meta-authoring-trail-and-audience-gates | acceptance[2], negative[0] | New `meta-view-audience-alternate-doors` (isolated). The admin saves a scratch list view gated on `manage_users`, plus a draft. A `setup.access`-only member does not see it in `GET /meta/view` and gets 403 `PERMISSION_DENIED` on the plain read, `/published`, `/layers` and `?state=draft`. On `/history` that member gets 403 `FORBIDDEN` first, while a `studio.access` member gets the audience refusal `PERMISSION_DENIED`. The admin reads every door. When the member replays the admin's ETag, the answer is 403, never 304, beside the control that the admin's own replay is a 304 | | security-catalog-activation-and-clone-doors | acceptance[2], [3] | `catalog-activation-door`: switching off `everyone` or `guest` is refused 400 naming the anchor, and no row is written. `permission-set-clone-door`: the source's own name is 409 `RESOURCE_CONFLICT`. A closed body (a key missing, or an extra key) is 400 `VALIDATION_FAILED` and nothing is saved. An anonymous caller is 401 | | security-catalog-one-holder-and-boot-report | acceptance[1], [2], [4] | New `security-catalog-one-holder-http` (isolated). `POST /marketplace/install-local` with an inline manifest redeclaring `showcase_auditor` answers 422 `PLUGIN_REGISTER_FAILED`, naming the set and `com.example.showcase`, and `GET /packages` is unchanged. A permission-set save whose body `packageId` names another package answers 400 `VALIDATION_ERROR` at `packageId`. The boot log, captured across two boots of one database file: the clean boot logs one INFO line; after 51 grants naming no catalog set are planted, the next boot logs one ERROR line with the grant ids capped at 50 (`more: 1`) | | grant-table-write-contract | acceptance[1], [2], [3] | New `grant-table-write-contract` (isolated), over `POST /data/sys_user_permission_set`. `permission_set_id` gets 400 `INVALID_FIELD`. A duplicate grant gets 409 `UNIQUE_VIOLATION`. A grant naming someone else as `granted_by` stores the admin. `permission-set-assignment-window` gains the authored message, the absence of the `unevaluable` constraint, and the valid-window 201 control | Ledger: - The five items each get a rewritten `automated.ref` that maps clauses to cases, `revision` +1, and a `history` row citing #26571. - As the seat's slice-3 order asks, activation-write-operator-gate, platform-owner-email-anchor and record-view-read-audit move from `automated.kind` `unit` to `dogfood`. Each gets `revision` +1 and a history row; their refs are unchanged. - security-catalog-one-holder-and-boot-report moves to `dogfood` too, because its rewritten ref now leads with the dogfood pins. The order named only the three slice-2 items, so this one is a deviation, listed below. Clauses left as they are, each with its reason in the item's `automated.ref`: - Every item's acceptance[0] is the pin run itself (`test` oracle). - security-catalog-one-holder-and-boot-report acceptance[3] is pinned in-process by the cli's integration test, which the `build` oracle accepts. - grant-table-write-contract acceptance[4] is the walled-boot leg (knownGaps), scored from the engine pin. ## Tests Head `abf45d31`, before the final no-overlap merge of main, ran the eight touched files under `os-verify-lock`: `Test Files 8 passed (8)`, `Tests 127 passed (127)` (`VERDICT command-exit 0`). `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The merge that followed brought in only `plugin-auth`'s set-manager change, other area ledgers and unrelated dogfood files. The full dogfood battery is CI's. ## Acceptance notes - anonymous-floor-v18-doors: `GET /api/v1/keys` answers the admin 405 `METHOD_NOT_ALLOWED`, because the dispatcher registers every verb on `/keys` (#23909) and the domain serves POST alone. The admin control there is therefore omitted, and the anonymous 401 is the floor's by the same argument the item's knownGaps makes. - meta-authoring-trail-and-audience-gates: the scratch view's column is `title`. A `name` column is refused at author time (`list-view-field-unknown`), because `showcase_task` has no `name` field. - security-catalog-one-holder-and-boot-report: the scratch package id is `com.example.qa-one-holder`, because install-local refuses an underscore in a package id with 400 `PLUGIN_MANIFEST_INVALID` before registration. ## Verification Local gate subset on head `6f533694` (origin/main merged): `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 d887aa9 commit 9f40488

9 files changed

Lines changed: 658 additions & 17 deletions

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

Lines changed: 65 additions & 17 deletions
Large diffs are not rendered by default.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.anonymous-floor-v18-doors`, acceptance[1],
4+
// acceptance[3] and acceptance[4], over HTTP on the stock showcase.
5+
//
6+
// `anonymous-door-sweep.dogfood.test.ts` sends one unauthenticated request per
7+
// registered route and scores the everything-else class by status and an error
8+
// envelope; it never reads `error.code`, its share-link token class accepts
9+
// any 4xx (401 included), and its form submit sends no forged field. The item
10+
// scores those legs at the `api` oracle, so this file drives exactly the doors
11+
// its steps list:
12+
//
13+
// - acceptance[1]: each listed v18 door answers an anonymous caller 401 with
14+
// its family's unauthenticated code, and the admin's identical call is
15+
// served (the door itself serves) — except `GET /keys`, which the domain
16+
// answers 405 because `/keys` serves POST alone, so its 401 is the floor's
17+
// by the same argument;
18+
// - acceptance[3]: the share-link token face is outside the floor — a bogus
19+
// token gets the face's own refusal, never 401;
20+
// - acceptance[4]: an anonymous public-form submit that forges `owner_id`
21+
// and a non-whitelisted `status` answers 201 with a body that is exactly
22+
// `{ id }`.
23+
//
24+
// acceptance[2] (the retired `/docs` path, anonymous and as the admin) is
25+
// pinned in `showcase-anonymous-deny-surfaces.dogfood.test.ts`. Isolated boot:
26+
// the form submit lands a row.
27+
28+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
29+
import { type VerifyStack } from '@objectstack/verify';
30+
import { bootShowcase } from './showcase-boot.js';
31+
32+
const SYS = { isSystem: true } as const;
33+
/** The two spellings the item accepts for the floor's machine code, per family. */
34+
const UNAUTHENTICATED_CODES = ['UNAUTHENTICATED', 'AUTH_REQUIRED'];
35+
36+
/** steps[0]'s doors, by full path (the branding door is outside `/api/v1`). */
37+
const DOORS = [
38+
'/api/v1/notifications',
39+
'/api/v1/keys',
40+
'/api/v1/share-links',
41+
'/api/v1/openapi.json',
42+
'/api/v1/i18n/locales',
43+
'/api/settings/branding',
44+
] as const;
45+
46+
const codeOf = (body: any) =>
47+
(body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null;
48+
49+
describe('the v18 anonymous floor, door by door, on the stock showcase', () => {
50+
let stack: VerifyStack;
51+
let admin: string;
52+
let ql: any;
53+
54+
const call = async (path: string, token?: string, init: RequestInit = {}) => {
55+
const headers: Record<string, string> = { 'content-type': 'application/json', ...(init.headers as Record<string, string>) };
56+
if (token) headers.authorization = `Bearer ${token}`;
57+
const res = await stack.raw(path, { ...init, headers });
58+
const text = await res.text();
59+
let body: any = null;
60+
try { body = JSON.parse(text); } catch { /* not JSON */ }
61+
return { status: res.status, body, text };
62+
};
63+
64+
beforeAll(async () => {
65+
stack = await bootShowcase();
66+
admin = await stack.signIn();
67+
ql = await stack.kernel.getServiceAsync('objectql');
68+
}, 120_000);
69+
70+
afterAll(async () => {
71+
await stack?.stop();
72+
});
73+
74+
it.each(DOORS)('acceptance[1]: anonymous GET %s answers 401 with the unauthenticated code', async (path) => {
75+
const r = await call(path, undefined, { method: 'GET' });
76+
expect(r.status, r.text).toBe(401);
77+
expect(UNAUTHENTICATED_CODES, `the code on ${path}: ${r.text}`).toContain(codeOf(r.body));
78+
});
79+
80+
// `/keys` serves POST alone (#23909 registers every verb so the floor answers
81+
// them all), so its GET is the domain's 405 to the admin and is no control.
82+
it.each(DOORS.filter((d) => d !== '/api/v1/keys'))('acceptance[1]: the admin\'s GET %s is served — the door itself serves', async (path) => {
83+
const r = await call(path, admin, { method: 'GET' });
84+
expect(r.status, r.text).toBe(200);
85+
});
86+
87+
it('acceptance[3]: a bogus share-link token answers the token face\'s own refusal, never the floor\'s 401', async () => {
88+
const r = await call('/api/v1/share-links/BOGUS/resolve', undefined, { method: 'GET' });
89+
expect(r.status, r.text).not.toBe(401);
90+
expect(r.status, 'a refusal, not a serve').toBeGreaterThanOrEqual(400);
91+
expect(r.status).toBeLessThan(500);
92+
expect(UNAUTHENTICATED_CODES, 'not the floor\'s code').not.toContain(codeOf(r.body));
93+
});
94+
95+
it('acceptance[4]: an anonymous submit forging owner_id and status answers 201 with exactly { id }', async () => {
96+
const marker = `anon-floor-v18-${Date.now()}`;
97+
const r = await call('/api/v1/forms/contact-us/submit', undefined, {
98+
method: 'POST',
99+
body: JSON.stringify({
100+
name: marker,
101+
email: 'anon.floor@example.com',
102+
company: 'Floor Co',
103+
message: 'v18 floor',
104+
owner_id: 'usr_forged_owner',
105+
status: 'qualified',
106+
}),
107+
});
108+
expect(r.status, r.text).toBe(201);
109+
expect(Object.keys(r.body ?? {}), 'nothing stored, defaulted or stamped is echoed').toEqual(['id']);
110+
const [row] = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS });
111+
expect(row?.id, 'the submission landed').toBe(r.body.id);
112+
expect(row.owner_id, 'the forged owner was not stored').not.toBe('usr_forged_owner');
113+
expect(row.status, 'the non-whitelisted status was not stored').not.toBe('qualified');
114+
});
115+
});

‎packages/qa/dogfood/test/catalog-activation-door.dogfood.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,19 @@ describe('the catalog activation door, on a booted showcase', () => {
262262
expect(await rowsOf('position', 'no_such_position')).toEqual([]);
263263
});
264264

265+
// [#26571] access-security.security-catalog-activation-and-clone-doors
266+
// acceptance[2] over HTTP: the audience anchors are not switchable here.
267+
for (const anchor of ['everyone', 'guest']) {
268+
it(`the audience anchor \`${anchor}\` is refused 400 and nothing is written`, async () => {
269+
const res = await flip('position', anchor, { enabled: false });
270+
const text = await res.text();
271+
272+
expect(res.status, text).toBe(400);
273+
expect(text).toContain(`'${anchor}' is an audience anchor`);
274+
expect(await rowsOf('position', anchor)).toEqual([]);
275+
});
276+
}
277+
265278
it('a type with no switch is refused 400 by the door itself — the route is the door\'s, not a router miss', async () => {
266279
const res = await flip('capability', 'manage_metadata', { enabled: false });
267280
const text = await res.text();
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.grant-table-write-contract`, acceptance[1] and
4+
// acceptance[3], through the REST data door on the stock showcase.
5+
//
6+
// The plugin-security engine pins (`grant-permission-set-name.test.ts`,
7+
// `granted-by-writer-provenance.test.ts`) prove the contract on a real engine
8+
// and read the thrown error through `resolveThrownHttpError`. The item's
9+
// clauses are scored at the `api` oracle, so this file writes the grant table
10+
// the way an administrator does — `POST /data/sys_user_permission_set` — and
11+
// reads the answer the wire carries:
12+
//
13+
// - acceptance[1]: a grant naming the retired `permission_set_id` column is
14+
// 400 INVALID_FIELD; a second identical grant is 409 UNIQUE_VIOLATION;
15+
// - acceptance[3]: a grant naming someone else as `granted_by` stores the
16+
// caller.
17+
//
18+
// acceptance[2] (the inverted window) is pinned beside the window semantics in
19+
// `permission-set-assignment-window.dogfood.test.ts`. acceptance[4] is the
20+
// walled-boot leg (knownGaps).
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import { type VerifyStack } from '@objectstack/verify';
24+
import { bootShowcase } from './showcase-boot.js';
25+
import { catalogPermissionSet } from './catalog-permission-set.js';
26+
27+
const SYS = { isSystem: true } as const;
28+
const GRANTS = '/data/sys_user_permission_set';
29+
const SET = 'showcase_auditor';
30+
31+
const codeOf = (body: any) => body?.code ?? body?.error?.code;
32+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id;
33+
34+
describe('the grant table\'s write contract over the REST data door', () => {
35+
let stack: VerifyStack;
36+
let ql: any;
37+
let admin: string;
38+
let adminId: string;
39+
const uid: Record<string, string> = {};
40+
41+
const userIdOf = async (email: string) => String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? '');
42+
43+
beforeAll(async () => {
44+
stack = await bootShowcase();
45+
admin = await stack.signIn();
46+
ql = await stack.kernel.getServiceAsync('objectql');
47+
const session = (await (await stack.apiAs(admin, 'GET', '/auth/get-session')).json()) as any;
48+
adminId = String(session?.user?.id ?? '');
49+
expect(adminId, 'the admin has an id').toBeTruthy();
50+
expect(await catalogPermissionSet(ql, SET), `${SET} is served by the catalog`).toBeTruthy();
51+
for (const who of ['dup', 'retired', 'provenance', 'granter']) {
52+
const email = `grant-contract-${who}@verify.test`;
53+
await stack.signUp(email);
54+
uid[who] = await userIdOf(email);
55+
expect(uid[who], `${who} signed up`).toBeTruthy();
56+
}
57+
}, 120_000);
58+
59+
afterAll(async () => {
60+
await stack?.stop();
61+
});
62+
63+
it('acceptance[1]: a grant naming the retired permission_set_id column is 400 INVALID_FIELD, and nothing is stored', async () => {
64+
const res = await stack.apiAs(admin, 'POST', GRANTS, { user_id: uid.retired, permission_set_id: SET });
65+
const body = await res.json();
66+
expect([res.status, codeOf(body)], JSON.stringify(body)).toEqual([400, 'INVALID_FIELD']);
67+
expect(JSON.stringify(body), 'the refusal names the column').toContain('permission_set_id');
68+
expect(await ql.find('sys_user_permission_set', { where: { user_id: uid.retired }, context: SYS })).toEqual([]);
69+
});
70+
71+
it('acceptance[1]: a second identical grant is 409 UNIQUE_VIOLATION, and one row stands', async () => {
72+
const first = await stack.apiAs(admin, 'POST', GRANTS, { user_id: uid.dup, permission_set: SET });
73+
expect(first.status, await first.clone().text()).toBe(201);
74+
const second = await stack.apiAs(admin, 'POST', GRANTS, { user_id: uid.dup, permission_set: SET });
75+
const body = await second.json();
76+
expect([second.status, codeOf(body)], JSON.stringify(body)).toEqual([409, 'UNIQUE_VIOLATION']);
77+
expect(await ql.find('sys_user_permission_set', { where: { user_id: uid.dup }, context: SYS })).toHaveLength(1);
78+
});
79+
80+
it('acceptance[3]: a grant naming ANOTHER user as granted_by stores the caller', async () => {
81+
const res = await stack.apiAs(admin, 'POST', GRANTS, {
82+
user_id: uid.provenance, permission_set: SET, granted_by: uid.granter,
83+
});
84+
expect(res.status, await res.clone().text()).toBe(201);
85+
const id = idOf(await res.json());
86+
expect(id, 'the created grant id').toBeTruthy();
87+
88+
const read = await stack.apiAs(admin, 'GET', `${GRANTS}/${id}`);
89+
expect(read.status).toBe(200);
90+
const row = (await read.json()) as any;
91+
expect((row?.record ?? row?.data ?? row)?.granted_by, 'the wire read-back names the writer').toBe(adminId);
92+
const stored = await ql.findOne('sys_user_permission_set', { where: { id }, context: SYS });
93+
expect(stored?.granted_by, 'and so does the stored row').toBe(adminId);
94+
});
95+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.meta-authoring-trail-and-audience-gates`,
4+
// acceptance[2] and negative[0], over HTTP on the stock showcase.
5+
//
6+
// A view that names `requiredPermissions` is served only to a caller holding
7+
// every one of them (#22639). `meta-view-dashboard-audience-gate.test.ts`
8+
// drives RestServer over a mock protocol and covers the list prune, the plain
9+
// by-name refusal and the cached arm; it has no `/published`, `/layers` or
10+
// `?state=draft` door, no `/history` leg for an authoring-trail holder, and no
11+
// real ETag path. This file drives those doors on a booted showcase:
12+
//
13+
// - a scratch list view `showcase_task.qa_gated_view` gated on a capability
14+
// neither member holds, saved by the admin with a pending draft;
15+
// - a `setup.access`-only member: the view is absent from `GET /meta/view`,
16+
// and every by-name door (plain, `/published`, `/layers`, `?state=draft`)
17+
// answers 403 PERMISSION_DENIED; `/history` answers 403 FORBIDDEN, the
18+
// authoring-trail gate refusing first;
19+
// - a `studio.access` member (admitted by the trail gate): `/history`
20+
// answers the audience refusal, 403 PERMISSION_DENIED;
21+
// - the admin reads the view on every door (the control);
22+
// - negative[0]: the admin's ETag replayed by the member as `If-None-Match`
23+
// is never a 304.
24+
//
25+
// acceptance[1] (setup.access-only on history/audit/diff/commits) is pinned
26+
// by `wall-authoring-trail-doors.dogfood.test.ts`. Isolated boot: the file
27+
// saves metadata.
28+
29+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
30+
import { type VerifyStack } from '@objectstack/verify';
31+
import { bootShowcase } from './showcase-boot.js';
32+
33+
const SYS = { isSystem: true } as const;
34+
const VIEW = 'showcase_task.qa_gated_view';
35+
const ITEM = `/meta/view/${VIEW}`;
36+
/** A declared capability the admin holds and neither member does. */
37+
const GATE = 'manage_users';
38+
const SETUP_SET = 'qa_audience_setup_only';
39+
const STUDIO_SET = 'qa_audience_studio';
40+
41+
const codeOf = (body: any) =>
42+
(body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null;
43+
44+
describe('a gated view on every alternate /meta door (#22639)', () => {
45+
let stack: VerifyStack;
46+
let ql: any;
47+
let admin: string;
48+
let setupOnly: string;
49+
let studio: string;
50+
51+
const read = async (token: string, path: string, headers: Record<string, string> = {}) => {
52+
const res = await stack.raw(`/api/v1${path}`, { method: 'GET', headers: { authorization: `Bearer ${token}`, ...headers } });
53+
const text = await res.text();
54+
let body: any = null;
55+
try { body = JSON.parse(text); } catch { /* not JSON */ }
56+
return { status: res.status, body, text, etag: res.headers.get('etag') };
57+
};
58+
59+
const grant = async (email: string, set: string) => {
60+
const token = await stack.signUp(email);
61+
const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
62+
expect(userId, `${email} provisioned`).toBeTruthy();
63+
await ql.insert('sys_user_permission_set', { user_id: userId, permission_set: set }, { context: SYS });
64+
return token;
65+
};
66+
67+
beforeAll(async () => {
68+
stack = await bootShowcase();
69+
admin = await stack.signIn();
70+
ql = await stack.kernel.getServiceAsync('objectql');
71+
72+
for (const [name, caps] of [[SETUP_SET, ['setup.access']], [STUDIO_SET, ['studio.access']]] as const) {
73+
const saved = await stack.apiAs(admin, 'PUT', `/meta/permission/${name}`, { name, label: name, objects: {}, systemPermissions: [...caps] });
74+
expect(saved.status, await saved.clone().text()).toBe(200);
75+
}
76+
setupOnly = await grant('audience-setup@verify.test', SETUP_SET);
77+
studio = await grant('audience-studio@verify.test', STUDIO_SET);
78+
79+
const body = {
80+
name: VIEW, object: 'showcase_task', viewKind: 'list', type: 'grid',
81+
label: 'QA gated view', columns: ['title'], requiredPermissions: [GATE],
82+
};
83+
const published = await stack.apiAs(admin, 'PUT', ITEM, body);
84+
expect(published.status, await published.clone().text()).toBe(200);
85+
const draft = await stack.apiAs(admin, 'PUT', `${ITEM}?mode=draft`, { ...body, label: 'QA gated view (draft)' });
86+
expect(draft.status, await draft.clone().text()).toBe(200);
87+
}, 120_000);
88+
89+
afterAll(async () => {
90+
await stack?.stop();
91+
});
92+
93+
it('guard the guard: neither member holds the gate; the setup member lacks the trail capabilities, the studio member holds studio.access', async () => {
94+
const perms = async (token: string) => {
95+
const p = (await (await stack.apiAs(token, 'GET', '/auth/me/permissions')).json()) as any;
96+
return ((p?.data ?? p)?.systemPermissions ?? []) as string[];
97+
};
98+
const setup = await perms(setupOnly);
99+
expect(setup).toContain('setup.access');
100+
expect(setup).not.toContain(GATE);
101+
expect(setup).not.toContain('studio.access');
102+
expect(setup).not.toContain('manage_metadata');
103+
const st = await perms(studio);
104+
expect(st).toContain('studio.access');
105+
expect(st).not.toContain(GATE);
106+
expect(await perms(admin)).toContain(GATE);
107+
});
108+
109+
it('the gated view is absent from the member\'s list and present in the admin\'s', async () => {
110+
const namesOf = (b: any) => ((b?.items ?? b?.data ?? b) as any[]).map((x: any) => x?.name);
111+
const member = await read(setupOnly, '/meta/view');
112+
expect(member.status, member.text).toBe(200);
113+
expect(namesOf(member.body)).not.toContain(VIEW);
114+
const control = await read(admin, '/meta/view');
115+
expect(namesOf(control.body)).toContain(VIEW);
116+
});
117+
118+
for (const [door, path] of [
119+
['by name', ITEM],
120+
['/published', `${ITEM}/published`],
121+
['/layers', `${ITEM}/layers`],
122+
['?state=draft', `${ITEM}?state=draft`],
123+
] as const) {
124+
it(`${door}: the member is refused 403 PERMISSION_DENIED; the admin reads it`, async () => {
125+
const member = await read(setupOnly, path);
126+
expect([member.status, codeOf(member.body)], member.text).toEqual([403, 'PERMISSION_DENIED']);
127+
const control = await read(admin, path);
128+
expect(control.status, control.text).toBe(200);
129+
});
130+
}
131+
132+
it('/history: a member the trail gate refuses gets 403 FORBIDDEN first; a studio.access member gets the audience refusal; the admin reads it', async () => {
133+
const setup = await read(setupOnly, `${ITEM}/history`);
134+
expect([setup.status, codeOf(setup.body)], setup.text).toEqual([403, 'FORBIDDEN']);
135+
const st = await read(studio, `${ITEM}/history`);
136+
expect([st.status, codeOf(st.body)], st.text).toEqual([403, 'PERMISSION_DENIED']);
137+
const control = await read(admin, `${ITEM}/history`);
138+
expect(control.status, control.text).toBe(200);
139+
});
140+
141+
it('negative[0]: the admin\'s ETag replayed by the member is never a 304', async () => {
142+
const control = await read(admin, ITEM);
143+
expect(control.status).toBe(200);
144+
expect(control.etag, 'the admin\'s read carries a validator').toBeTruthy();
145+
const replay = await read(setupOnly, ITEM, { 'if-none-match': control.etag! });
146+
expect(replay.status, 'a stale validator buys no 304').not.toBe(304);
147+
expect([replay.status, codeOf(replay.body)]).toEqual([403, 'PERMISSION_DENIED']);
148+
// Control: the admin's own validator IS a 304, so the replay above was a real one.
149+
expect((await read(admin, ITEM, { 'if-none-match': control.etag! })).status).toBe(304);
150+
});
151+
});

0 commit comments

Comments
 (0)