Skip to content

Commit 912076f

Browse files
test(dogfood): pin the last access-security checklist clauses at the oracle's layer, slice 4 (#26941)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8YSjPf3hTSLEBa52VUBikb4)_ Fixes #26571 (slice 4 of 4, items: access-security.parent-and-organization-write-guards, access-security.deployment-tables-platform-only, access-security.share-link-combined-gates) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:access-security 区最后 3 个清单条目补上 dogfood 断言(进程内 HTTP,即这些条款 oracle 指定的层),新增 3 个隔离用例文件,共钉住 8 条此前只在引擎层或单元层的条款。3 个条目的 `automated.ref` 逐条款改写,其中 deployment-tables-platform-only 和 share-link-combined-gates 的 `automated.kind` 改为 `dogfood`;每个条目 `revision` 加一、`history` 记本卡。本片之后 #26571 无剩余条目。 - 为什么改:18.2.0 Tier 1 记录 #26468 把这 3 条判为 PARTIAL:组织字段清空拒绝、表头存在性对等、部署级表的两类人员、分享链接组合关卡,都没有走 HTTP 的断言。 - 风险与回滚:只加测试和改清单文字,不动产品代码,不加 CI 通道。回滚即 revert 本 PR。 - 先看:① `parent-and-organization-write-guards.dogfood.test.ts`(posture-only 隔离启动下清空 organization_id 被 403,以及不可读与不存在表头返回相同 400);② `share-link-combined-gates.dogfood.test.ts`(匿名各变体字节级相同的拒绝体,/resolve 与 /messages 配对);③ Acceptance notes 里关于 controlled_by_parent 明细返回 403 而非 400 的说明。 - 两分钟验证:`cd packages/qa/dogfood && pnpm exec vitest run test/parent-and-organization-write-guards.dogfood.test.ts`(需先按 dogfood 惯例 build 依赖闭包),预期 `Tests 6 passed`。 - 需要你做的:无,席位落地。 ## What changes Each clause is pinned at the layer its oracle names: `api` is the in-process HTTP stack of `@objectstack/verify`. All changes are in `packages/qa/dogfood/**` plus the area ledger, with no product code changes. Every new assertion was written against today's behaviour and is green. All three files boot their own showcase (isolated), since each writes records, mints links or creates an organization. | item | clauses newly pinned | where | |---|---|---| | parent-and-organization-write-guards | acceptance[1], [2], negative[0] | New `parent-and-organization-write-guards`. acceptance[1]: a `multiTenant: 'posture-only'` boot (the tenancy service reports `isolated`, the wall active and not degraded, asserted). An organization owner who is not the platform administrator PATCHes their own `showcase_private_note` with `organization_id: null` and gets 403 `PERMISSION_DENIED`; the row keeps its organization, and the same caller's PATCH that leaves the column alone is admitted. Inserts sending `organization_id` `null` and `''` are stamped with the caller's active organization. acceptance[2] / negative[0]: a member holding a scratch set that writes `showcase_task` and whose row policy selects no `showcase_project` POSTs a task naming a seeded project it cannot read (asserted) and one naming a project id that exists nowhere. Both answer 400 with `reference_not_found`, and the bodies are equal field by field once the named id is normalised. Nothing lands, and the admin's identical POST lands. The same parity is measured on the `controlled_by_parent` invoice line, as described in the Acceptance notes | | deployment-tables-platform-only | acceptance[1], [2] | New `deployment-tables-platform-only`. A Default Organization member's `sys_member` role is promoted to `admin`. A guard asserts it resolves an `organization_admin` set, holds no `manage_platform_settings`, and is not the platform administrator. On each of the seven tables (`sys_job`, `sys_job_queue`, `sys_job_run`, `sys_migration`, `sys_migration_journal`, `sys_flow_dispatch`, `sys_presence`), `GET /api/v1/data/TABLE` is 403 `PERMISSION_DENIED` for the org admin and 200 for the platform administrator. The platform administrator's URL-encoded `organization_id` filter is 400 `INVALID_FIELD` on each | | share-link-combined-gates | acceptance[1], [2], [3], negative[0] | New `share-link-combined-gates`. The admin mints two links through `POST /api/v1/share-links` on the seeded published client brief. Link A (`signed_in` + password), resolved anonymously with no password, a wrong one and the right one (UTF-8 header form), answers 401 `SIGN_IN_REQUIRED` with three byte-identical bodies. Link B (`email` + password) answers 404 `INVALID_OR_EXPIRED` to five non-entitled variants, byte-identical with an unknown token's body; the allowlisted email with the right password resolves 200. The same eight calls are paired with `/messages`: 404 `NOT_FOUND` wherever `/resolve` refused, and 400 `UNSUPPORTED` on the one call it passed | Ledger: - Each of the three items gets a rewritten `automated.ref` that maps clauses to cases, `revision` +1, and a `history` row citing #26571. - deployment-tables-platform-only moves from `automated.kind` `unit` to `dogfood`, and share-link-combined-gates moves from `conformance` to `dogfood`, since each ref now leads with the dogfood pin. parent-and-organization-write-guards was already `dogfood`. 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). - parent-and-organization-write-guards acceptance[3] was already pinned over HTTP by `cbp-parent-attachment-comment-gates` and `cbp-explain-master-write`. No clause is left as a live leg. ## Tests On head `bc2b4dce` (origin/main merged), with the closure rebuilt, the three new files ran under `os-verify-lock`: `Test Files 3 passed (3)`, `Tests 25 passed (25)` (`VERDICT command-exit 0`). That is 15 in deployment-tables-platform-only, 4 in share-link-combined-gates and 6 in parent-and-organization-write-guards. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. The final merge of main, right before this PR, brought crud-permission-matrix's ledger row (#26923, no overlap with these three items), app-shell fixes, docs, and two runtime/metadata-protocol refusals that none of these doors read. The full dogfood battery is CI's. ## Acceptance notes - parent-and-organization-write-guards acceptance[2]: on the `controlled_by_parent` line (`showcase_invoice_line`), a stock contributor naming another contributor's invoice and one naming a missing invoice both get the ADR-0055 §3 master-edit gate's 403 `PERMISSION_DENIED`. That gate runs before the reference check. The two bodies are byte-identical, so negative[0] holds there, and the file pins that. The 400 `reference_not_found` comparison is pinned on `showcase_task`, a plain `master_detail` detail of `showcase_project`. That matches `engine-unreadable-master-detail-header.test.ts`, whose controls keep a `controlled_by_parent` detail unchanged. If the item's clause is meant to require 400 on the `controlled_by_parent` fixture too, that is a clause question for the seat, not a pin gap. - parent-and-organization-write-guards acceptance[2]: every showcase member reads projects through the stock baseline. So the scratch set narrows `showcase_project` with a row policy that selects nothing, and a guard asserts the member's by-id read is refused. - The item's knownGaps about provisioning a walled `os serve` boot are unchanged. They describe the live run's fixture. The pin uses the in-process `posture-only` walled boot that `no-active-organization-write-refusal` and `wall-catalog-refusal` already use. ## Verification Local gate subset on head `bc2b4dce` (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 e269d3b commit 912076f

4 files changed

Lines changed: 475 additions & 8 deletions

File tree

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4228,7 +4228,7 @@
42284228
"title": "Write guards tightened on v18: a walled update cannot null out organization_id, a detail write naming an unreadable header is reference_not_found, a validation rule reading an unreadable header answers like a missing one, and a controlled_by_parent child's attachments / comments / explain follow the master's edit grant",
42294229
"since": "v18",
42304230
"status": "active",
4231-
"revision": 6,
4231+
"revision": 7,
42324232
"priority": "P1",
42334233
"surface": "api",
42344234
"personas": [
@@ -4286,7 +4286,7 @@
42864286
],
42874287
"automated": {
42884288
"kind": "dogfood",
4289-
"ref": "packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts + packages/objectql/src/engine-unreadable-master-detail-header.test.ts + packages/objectql/src/engine-predicate-related-master-detail-header.test.ts + packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts + packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts + packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts. Layers (RUNNER rule 6): tenant-wall-organization-removal.test.ts and the two engine master-detail-header tests are in-process — a real engine and SecurityPlugin over SQL drivers, the answer read off the thrown statusCode or through resolveThrownHttpError — so acceptance[1], acceptance[2] and negative[0] (api oracle) score partial from them: no pin here drives those doors over HTTP. The 18.0 sweep's PASS for them at revision 4 (#24840) predates the layer rule; it is not a precedent for scoring them pass from these pins"
4289+
"ref": "packages/qa/dogfood/test/parent-and-organization-write-guards.dogfood.test.ts + packages/qa/dogfood/test/cbp-parent-attachment-comment-gates.dogfood.test.ts + packages/qa/dogfood/test/cbp-explain-master-write.dogfood.test.ts + packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts + packages/objectql/src/engine-unreadable-master-detail-header.test.ts + packages/objectql/src/engine-predicate-related-master-detail-header.test.ts + packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts. Per clause, at the api oracle's layer (in-process HTTP): acceptance[1] — parent-and-organization-write-guards' walled describe boots the showcase with multiTenant 'posture-only' (a real isolated posture: the tenancy service reports the Layer 0 wall active and not degraded, asserted); an organization owner who is not the platform administrator PATCHes their own showcase_private_note with organization_id null and is refused 403 PERMISSION_DENIED, the row keeping its organization (the same caller's PATCH that leaves the column alone is admitted, the control), and inserts sending organization_id null and '' are stamped with the caller's active organization. acceptance[2] and negative[0] — the same file: a member holding a scratch task-writer set whose row policy selects no showcase_project POSTs a showcase_task naming a seeded project it cannot read (asserted) and one naming a project id that exists nowhere; both answer 400 carrying reference_not_found, the two bodies equal field by field once the named id is normalised, and nothing lands (the admin's identical POST lands, the control). On the controlled_by_parent detail (showcase_invoice_line: a stock contributor naming another contributor's invoice, and a missing one) the ADR-0055 §3 master-edit gate answers before the reference check: both are the one 403 PERMISSION_DENIED body, so negative[0] holds there too; that code is the gate's by design (engine-unreadable-master-detail-header.test.ts's controls keep a controlled_by_parent detail unchanged). acceptance[3] — cbp-parent-attachment-comment-gates and cbp-explain-master-write. The plugin-security and objectql files are engine pins beneath (a real engine and SecurityPlugin over SQL drivers). acceptance[0] is the pin run itself"
42904290
},
42914291
"source": [
42924292
"packages/plugins/plugin-security/src/security-plugin.ts#SecurityPlugin",
@@ -4330,6 +4330,12 @@
43304330
"date": "2026-10-26",
43314331
"change": "One checklist-accuracy finding of Tier 1 record #25815 (a1/a2/n0), re-read on main b4199c96: the walled organization-removal refusal and the unreadable-header parity are pinned only in-process (engine + SecurityPlugin over SQL drivers, thrown statusCode / resolveThrownHttpError), with no HTTP pin for the api-oracle legs; #24840 scored them PASS at revision 4 before the layer rule. automated.ref names the layer and the precedent. No clause or status changed",
43324332
"ref": "#25835"
4333+
},
4334+
{
4335+
"revision": 7,
4336+
"date": "2026-10-27",
4337+
"change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL). Pinned over the REST data door: acceptance[1] on a posture-only walled showcase boot (the organization-emptying PATCH refused 403 PERMISSION_DENIED, empty-org inserts stamped); acceptance[2] and negative[0] as the unreadable/nonexistent master comparison, field by field, on a plain master_detail detail, and the same parity on the controlled_by_parent line, where the master-edit gate's 403 answers first (new parent-and-organization-write-guards). No clause or status changed",
4338+
"ref": "#26571"
43334339
}
43344340
]
43354341
},
@@ -4338,7 +4344,7 @@
43384344
"title": "Seven deployment tables (sys_job, sys_job_run, sys_job_queue, sys_flow_dispatch, sys_migration, sys_migration_journal, sys_presence) carry no organization column on v18 and are readable only with manage_platform_settings — an org admin who is not platform admin is refused even on single",
43394345
"since": "v18",
43404346
"status": "active",
4341-
"revision": 3,
4347+
"revision": 4,
43424348
"priority": "P1",
43434349
"surface": "api",
43444350
"personas": [
@@ -4381,8 +4387,8 @@
43814387
"wrong-persona"
43824388
],
43834389
"automated": {
4384-
"kind": "unit",
4385-
"ref": "packages/platform-objects/src/deployment-level-no-tenant-column.test.ts (sys_job, sys_job_queue, sys_job_run, sys_migration, sys_migration_journal) + packages/services/service-automation/src/sys-flow-dispatch.object.test.ts (sys_flow_dispatch) + packages/services/service-realtime/src/objects/sys-presence.object.test.ts (sys_presence) — object-declaration pins (no tenant column, requiredPermissions manage_platform_settings); they score acceptance[0] only. No pin drives either door over HTTP: acceptance[1] (org admin 403, platform admin 200) and acceptance[2] (400 INVALID_FIELD on an organization_id filter) stay live-run clauses"
4390+
"kind": "dogfood",
4391+
"ref": "packages/qa/dogfood/test/deployment-tables-platform-only.dogfood.test.ts + packages/platform-objects/src/deployment-level-no-tenant-column.test.ts (sys_job, sys_job_queue, sys_job_run, sys_migration, sys_migration_journal) + packages/services/service-automation/src/sys-flow-dispatch.object.test.ts (sys_flow_dispatch) + packages/services/service-realtime/src/objects/sys-presence.object.test.ts (sys_presence). Per clause, at the api oracle's layer (in-process HTTP): acceptance[1] — deployment-tables-platform-only signs up a member of the Default Organization and promotes its sys_member role to admin (guarded: it resolves an organization_admin permission set, holds no manage_platform_settings and is not the platform administrator), then reads GET /api/v1/data/TABLE for each of the seven tables: the org admin is refused 403 PERMISSION_DENIED on every one, and the platform administrator is served 200. acceptance[2] — the platform administrator's GET with the URL-encoded filter [[\"organization_id\",\"=\",\"x\"]] answers 400 INVALID_FIELD on each of the seven. The three object-declaration pins (no tenant column, requiredPermissions manage_platform_settings) are beneath it. acceptance[0] is the pin run itself"
43864392
},
43874393
"source": [
43884394
"packages/platform-objects/src/audit/sys-job.object.ts",
@@ -4407,6 +4413,12 @@
44074413
"date": "2026-10-26",
44084414
"change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a0: automated.ref named packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts, which pins SecurityPlugin's boot refusal when sys_user / sys_member / sys_organization are not registered — nothing about the seven deployment tables or manage_platform_settings — so it left the ref. a1/a2: the ref now says no pin drives either door over HTTP and both clauses stay live-run. No clause or status changed",
44094415
"ref": "#25835"
4416+
},
4417+
{
4418+
"revision": 4,
4419+
"date": "2026-10-27",
4420+
"change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL: no pin drove either door over HTTP). Pinned over the REST data door: acceptance[1]'s org admin 403 PERMISSION_DENIED and platform admin 200 on all seven tables, and acceptance[2]'s 400 INVALID_FIELD on an organization_id filter (new deployment-tables-platform-only). automated.kind unit -> dogfood, since the ref now leads with the dogfood pin. No clause or status changed",
4421+
"ref": "#26571"
44104422
}
44114423
]
44124424
},
@@ -4415,7 +4427,7 @@
44154427
"title": "Share links with two gates combined: signed_in + password answers an anonymous caller 401 SIGN_IN_REQUIRED whatever password is sent, email + password answers a uniform 404 INVALID_OR_EXPIRED, and /messages opens exactly where /resolve opens",
44164428
"since": "v18",
44174429
"status": "active",
4418-
"revision": 4,
4430+
"revision": 5,
44194431
"priority": "P2",
44204432
"surface": "api",
44214433
"personas": [
@@ -4464,8 +4476,8 @@
44644476
"a password-correct anonymous caller reading link A is an authorization FAIL — carve-out applies"
44654477
],
44664478
"automated": {
4467-
"kind": "conformance",
4468-
"ref": "packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts + packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts + packages/runtime/src/domains/share-links-messages-audience.test.ts (acceptance[3]: `/messages` opens for a caller exactly when `/resolve` opens the same link, on both mounts) + packages/runtime/src/domains/share-links-refusal-order.test.ts (acceptance[1] and acceptance[2]: the anonymous no-password-oracle row and the email-audience identical-refusal rows). Layers (RUNNER rule 6): the two runtime files are in-process — the domain's route entries kept by a minimal IHttpServer stand-in over a real engine, no Hono and no HTTP — so they score acceptance[0] only; acceptance[1] to acceptance[3] (api oracle) score partial from them, and their live legs are driven over HTTP"
4479+
"kind": "dogfood",
4480+
"ref": "packages/qa/dogfood/test/share-link-combined-gates.dogfood.test.ts + packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts + packages/plugins/plugin-sharing/src/share-link-envelope.conformance.test.ts + packages/runtime/src/domains/share-links-messages-audience.test.ts + packages/runtime/src/domains/share-links-refusal-order.test.ts. Per clause, at the api oracle's layer (in-process HTTP): the dogfood file mints two links through POST /api/v1/share-links on the seeded published client brief and drives the public token routes anonymously. acceptance[1] and negative[0] — link A (audience signed_in + password) resolved with no password, a wrong one and the right one in the UTF-8 header form answers 401 SIGN_IN_REQUIRED, the three bodies byte-identical. acceptance[2] — link B (audience email + password): no email and no password, the right password with no email, an off-list email with the right password, and the allowlisted email with a wrong password and with none all answer 404 INVALID_OR_EXPIRED, byte-identical with an unknown token's body; the allowlisted email with the right password resolves 200 (the control). acceptance[3] — the same eight calls paired with /messages: 404 NOT_FOUND on every call /resolve refused, and 400 UNSUPPORTED (a brief is no ai_conversations record) on the one call /resolve passed. The plugin-sharing and runtime files are beneath (the route entries over a real engine, no HTTP). acceptance[0] is the pin run itself"
44694481
},
44704482
"source": [
44714483
"packages/plugins/plugin-sharing/src/share-link-routes.ts#registerShareLinkRoutes",
@@ -4496,6 +4508,12 @@
44964508
"date": "2026-10-26",
44974509
"change": "Two checklist-accuracy findings of Tier 1 record #25815, re-read on main b4199c96. a1/a2: packages/runtime/src/domains/share-links-refusal-order.test.ts asserts the anonymous no-password-oracle and email-audience identical-refusal rows but was not in the ref; it joins it. a3: share-links-messages-audience.test.ts drives in-process route entries (no Hono, no HTTP), so it scores acceptance[0], not the api oracle of acceptance[3]. automated.ref names the layer of both. No clause or status changed",
44984510
"ref": "#25835"
4511+
},
4512+
{
4513+
"revision": 5,
4514+
"date": "2026-10-27",
4515+
"change": "Access-security pin upgrade, slice 4 (#26571; Tier 1 record #26468 scored the item PARTIAL: the runtime pins drive the route entries with no HTTP). Pinned over HTTP on anonymous calls: acceptance[1] and negative[0]'s byte-identical 401 SIGN_IN_REQUIRED across the password variants, acceptance[2]'s byte-identical 404 INVALID_OR_EXPIRED across the non-entitled variants and the unknown token with the allowlisted 200 control, and acceptance[3]'s resolve/messages pairing (new share-link-combined-gates). automated.kind conformance -> dogfood, since the ref now leads with the dogfood pin. No clause or status changed",
4516+
"ref": "#26571"
44994517
}
45004518
]
45014519
},
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#26571] `access-security.deployment-tables-platform-only`, acceptance[1]
4+
// and acceptance[2], over the REST data door on the stock showcase.
5+
//
6+
// Seven tables are deployment-level: they carry no tenant column and declare
7+
// `requiredPermissions: ['manage_platform_settings']` (the object-declaration
8+
// pins in platform-objects, service-automation and service-realtime). The item
9+
// scores the doors at the `api` oracle, so this file reads them as the two
10+
// personas the item names:
11+
//
12+
// - an organization administrator (the Default Organization's membership
13+
// role promoted to `admin`, which provisions the organization-admin set)
14+
// who is not the platform administrator: 403 PERMISSION_DENIED on every
15+
// table;
16+
// - the platform administrator: 200 on every table;
17+
// - acceptance[2]: an `organization_id` filter is 400 INVALID_FIELD, since
18+
// the column does not exist on these tables.
19+
//
20+
// Isolated boot: the file promotes a membership.
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+
26+
const SYS = { context: { isSystem: true } } as const;
27+
const ORG_ADMIN = 'deployment-tables-org-admin@verify.test';
28+
const PASSWORD = 'Deploy-Tables-26571';
29+
const TABLES = [
30+
'sys_job', 'sys_job_queue', 'sys_job_run', 'sys_migration', 'sys_migration_journal', 'sys_flow_dispatch', 'sys_presence',
31+
] as const;
32+
33+
const codeOf = (body: any) =>
34+
(body?.error && typeof body.error === 'object' ? body.error.code : undefined) ?? body?.code ?? null;
35+
36+
describe('deployment-level tables are the platform administrator\'s alone', () => {
37+
let stack: VerifyStack;
38+
let admin: string;
39+
let orgAdmin: string;
40+
41+
beforeAll(async () => {
42+
stack = await bootShowcase();
43+
admin = await stack.signIn();
44+
const ql: any = await stack.kernel.getServiceAsync('objectql');
45+
orgAdmin = await stack.signUp(ORG_ADMIN, PASSWORD);
46+
const userId = (await ql.findOne('sys_user', { where: { email: ORG_ADMIN }, ...SYS }))?.id;
47+
expect(userId, 'the org admin is provisioned').toBeTruthy();
48+
// Promote the way an organization does: the membership role.
49+
const [membership] = await ql.find('sys_member', { where: { user_id: userId }, ...SYS });
50+
expect(membership?.organization_id, 'a member of the Default Organization').toBeTruthy();
51+
await ql.update('sys_member', { id: membership.id, role: 'admin' }, SYS);
52+
// A fresh session reads the promoted role.
53+
orgAdmin = await stack.signIn(ORG_ADMIN, PASSWORD);
54+
}, 120_000);
55+
56+
afterAll(async () => {
57+
await stack?.stop();
58+
});
59+
60+
it('guard the guard: the org admin holds the organization-admin standing and is not the platform administrator', async () => {
61+
const session = (await (await stack.apiAs(orgAdmin, 'GET', '/auth/get-session')).json()) as any;
62+
expect(session.user?.isPlatformAdmin, 'not the platform administrator').not.toBe(true);
63+
expect(session.user?.positions ?? []).not.toContain('platform_admin');
64+
const p = (await (await stack.apiAs(orgAdmin, 'GET', '/auth/me/permissions')).json()) as any;
65+
const resolved = p?.data ?? p;
66+
expect((resolved.permissionSets ?? []).map(String).join(','), 'the organization-admin set is held').toMatch(/organization_admin/);
67+
expect(resolved.systemPermissions ?? [], 'no platform-settings capability').not.toContain('manage_platform_settings');
68+
});
69+
70+
it.each(TABLES)('acceptance[1]: %s — the org admin is refused 403 PERMISSION_DENIED; the platform admin reads 200', async (table) => {
71+
const refused = await stack.apiAs(orgAdmin, 'GET', `/data/${table}`);
72+
const body = await refused.json();
73+
expect([refused.status, codeOf(body)], JSON.stringify(body)).toEqual([403, 'PERMISSION_DENIED']);
74+
const served = await stack.apiAs(admin, 'GET', `/data/${table}`);
75+
expect(served.status, await served.clone().text()).toBe(200);
76+
});
77+
78+
it.each(TABLES)('acceptance[2]: %s — an organization_id filter is 400 INVALID_FIELD (the column does not exist)', async (table) => {
79+
const filter = encodeURIComponent(JSON.stringify([['organization_id', '=', 'x']]));
80+
const res = await stack.apiAs(admin, 'GET', `/data/${table}?filter=${filter}`);
81+
const body = await res.json();
82+
expect([res.status, codeOf(body)], JSON.stringify(body)).toEqual([400, 'INVALID_FIELD']);
83+
});
84+
});

0 commit comments

Comments
 (0)