Skip to content

Commit a7ab047

Browse files
fix(rest): a public form that cannot take intake on a walled posture is not offered; the admin read says why (#21580)
Part of #21476 Clause-②: no This delivers the doors half and the administrator's read half of the triage ruling (`5962758813`). The publish half is left open on purpose: its contract-faithful channel sits behind `packages/metadata-protocol/src/protocol.ts`, which open PRs hold. The call sites are under "Not in this PR". The keyword is `Part of`, so the card stays open after this merges, while that half waits for a decision. ## What On a walled tenancy posture, a public form bound to an object walled by an organization column is no longer offered to anonymous visitors. An anonymous submission carries no organization. On a walled posture the engine refuses an insert without one into such an object (`resolveSystemInsertOrganization`). So the form was served (`GET /forms/contact-us` 200), and then every submit answered `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. This was reproduced on `origin/main` (`6dd99b82c3`) with `bootStack(showcaseStack, { multiTenant: 'posture-only' })`. - **One predicate.** `anonymousFormIntakeUnavailability` in `packages/rest/src/rest-server.ts` returns null when the form can take intake, otherwise why it cannot. It reads two facts and restates neither: - the tenancy service's in-force `posture`. This is the value SecurityPlugin hands the engine (`setTenancyPostureProvider`), so a degraded walled request reads `single` there, and the engine derives the install's organization. - the wall column, from `@objectstack/metadata-core`'s existing `resolveRecordWallOrganizationField`, read over the served object schema (which carries the injected `organization_id`). The object is read only once a wall is in force, so single-posture deployments pay nothing new. - **Both doors read it in one place.** It is called inside `resolveFormBySlug`, the one resolution both `GET /forms/:slug` and `POST /forms/:slug/submit` already call. An unavailable form resolves to `null`, which is exactly the withdrawn form's `404 FORM_NOT_FOUND`, byte for byte. Anonymous callers learn nothing about the tenancy, and there is no second check per door. - **The administrator's read names why.** `GET /meta/view/:name` puts one warning in `item._diagnostics.warnings` per unavailable open form. It sits on both arms (cached and uncached), is located at the form's `sharing` (`config.sharing`, `formViews.KEY.sharing` or `form.sharing`), and names the slug, the object, the wall column, the posture and the remedy (`tenancy: { enabled: false }` when the rows belong to no organization). The reason depends on facts the protocol's validator never hashes, so on the cached arm a `view`'s If-None-Match is compared in REST against an ETag with the reason folded in (the ADR-0106 D3 shape). With no reason, the ETag and the 304 are byte-identical to before (pinned). ### Placement: the predicate lives in `rest`, not `metadata-core` The dispatch assumed `metadata-core`'s `anonymous-form-intake.ts`. I measured that first: any new export there enlarges `@objectstack/metadata-core`'s published index (`export *`), which is a `Clause-②: yes (widening)` change by #21566's own grading. The dispatch pins `Clause-②: no`. Every reader of the predicate (two doors, one admin read) is in `rest-server.ts`. The predicate composes two rules that are already shared (`postureEnforcesWall` from spec, `resolveRecordWallOrganizationField` from metadata-core), so no rule gains a second spelling. It moves into `metadata-core` on the day a reader outside `rest` exists, for example the publish half's option A below. ## Pins - `packages/rest/src/public-form-intake-availability.test.ts` (16 tests): - The doors are enumerated off the registered routes. The set under `/forms/` must be exactly the two doors, and each door on each walled posture (`isolated`, `group`) is its own row: it answers the withdrawn form's answer byte for byte, and `createData` is never called. - Controls, all accepted: a `tenancy: { enabled: false }` object, the single posture (where the object is not even read), a degraded walled request, and no tenancy service. - Admin read on both arms: the located warning appears on the walled cases, and `_diagnostics` is untouched on the controls. - Validator: the bare protocol ETag revalidates into the reason, the folded ETag gives 304, and the control's ETag is unchanged and still gives 304. - `packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts` (real walled showcase boot): - both doors' raw answers equal the same form's answers once withdrawn env-wide on the same boot, and no `showcase_inquiry` row lands; - the admin read names the reason at `config.sharing`. - `public-form-withdrawal-walled.dogfood.test.ts`: the dogfood control. A tenancy-disabled object on the walled boot still accepts intake, and its admin read now also asserts that no intake warning is present. ## Ablation (one-shot, not kept) Each mutation was applied with `scripts/ablation-replace.mjs` against `packages/rest/src/rest-server.ts`, whose HEAD blob is `239ac2d6` at both `8a8839f9ab` and the final `66ee5294a6`. The direction was predicted before each run. - **A, unit leg: the doors stop reading the predicate.** `return unavailable ? null : { ...match, organizationId };` became `return { ...match, organizationId };` (anchor 1 to 0, blob `239ac2d6` to `43fdf709`). - Predicted: exactly the 4 door rows red, everything else green. - Observed: 4 failed, 12 passed. The GET rows received 200 and the POST rows 201 where 404 was expected; the admin-read rows stayed green because they reach the predicate from their own call site. - Restore: blob equals HEAD and `git diff HEAD` is empty. - **A, dist leg (dogfood).** - The first attempt was a no-op. The same replacement left `unavailable` unused, and the DTS build refused it (`noUnusedLocals`) after the JS bundle had already been emitted, so no test ran. I rebuilt from the restored source, and `ablation-dist-preflight` confirmed the guard present in `dist/index.js` and `dist/index.cjs` with a clean tree. - Re-run with `return unavailable && false ? null : { ...match, organizationId };` (blob `2b2e9c9f`), rebuilt; the preflight found the plant marker in 2 built files. - Predicted: 1 red. Observed: 1 failed (`expected 200 to be 404` on the doors row), 8 passed. - Restore: blob equals HEAD, rebuilt; the preflight found the guard in 2 files, the mutation absent from all 6, and a clean tree; the dogfood run went back to 9/9. - **B: the predicate itself answers "available".** `return tenantField === null ? null : ...` became `return tenantField === null || true ? null : ...` (blob `25ef3c8e`). - Predicted: 7 red (4 door rows, plus the 3 walled admin-read rows: uncached, cached, validator) and 9 green. - Observed: 7 failed, 9 passed. Restore: blob equals HEAD and the tree is clean. The pins asked for an ablation "on one door". There is no per-door read site to ablate: the predicate is read once, in the resolution both doors call. Ablation A removes that one read, and each door's own row goes red. ## Tests (at `66ee5294a6`, after merging `origin/main` `44072fc2b9`) - `@objectstack/rest` full suite (`--project local`): 258 files passed; 4883 tests passed, 326 skipped. `typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK. - `@objectstack/metadata-core`: 17 files / 311 passed; `typecheck` clean (it is unchanged). - `@objectstack/dogfood` `typecheck` clean. Public-form dogfood files (walled intake, walled withdrawal, showcase withdrawal, showcase public form, read-back masking): 5 files / 20 passed. - `@objectstack/runtime` `/meta` parity census, run because it reads `rest-server.ts` source and `rest`'s `dist/`. The four files `meta-list-projection-parity`, `meta-item-read-gate-parity`, `meta-read-org-scope-parity` and `meta-item-envelope` gave 780 passed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 68 commands; 67 exit 0. `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (it needs a full workspace build), so it is NOT MEASURED and left to CI. The `--ran` reconciliation accounted for 68 of 68: 67 run, 1 NOT MEASURED, 0 unrun. - eslint, narrowed to the 4 changed `.ts` files (`--no-inline-config --format json`): 4 files, 0 errors, 0 warnings, none ignored. The fifth changed path is a changeset `.md`. The narrowing is sound because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Not in this PR: the publish half The ruling asks the publish surfaces (`PUT /meta/view/:name`, `POST /meta/view/:name/publish`) to say why too. The only located, structured channel those responses carry is `advisories`, and both `SaveMetaItemResponseSchema` and `PublishMetaItemResponseSchema` declare the runtime authoring gate as its producer. The places that would have to change: - `packages/metadata-protocol/src/runtime-authoring-gate.ts`: a gate-local rule would sit beside `findPlatformScheduleOrgGaps`, around line 300. - `packages/metadata-protocol/src/protocol.ts:5612`: the gate is fed `orgWallEnforced: this.orgWallEnforced()`. - `protocol.ts:5938`: `orgWallEnforced()` reads the requested posture (`postureEnforcesWall(resolveTenancyPosture())`), which disagrees with the doors' in-force reading on a degraded deployment. - `protocol.ts:18673` and `protocol.ts:19612`: the attach sites. `protocol.ts` is held by open PRs #21545 and #21512, so this PR stops there. The options and a recommendation are in the report on the card. ## Acceptance notes - **Boundary of the predicate.** It reads declarations. The engine also passes a federated (`external`) object, a platform object its inventory has not admitted, and a row a `beforeInsert` hook stamped. A form bound to one of those that also carries a wall column is withheld here although the engine would accept it, which is the fail-closed direction. No shipped hook stamps `organization_id` (`git grep` over the CRM and showcase hooks: exit 1, with a `beforeInsert` control at exit 0). - **New failure mode on walled postures.** An object-metadata read failure now fails both doors closed (GET `500 FORM_RESOLVE_FAILED`; submit through `mapDataError`). Single-posture deployments make no new read. - **Cached arm.** For every `view` read, If-None-Match is now compared in REST rather than in the protocol. Server work is unchanged, because `getMetaItemCached` already delegates to `getMetaItem`. Response bytes, the ETag and the 304 are identical when there is no reason. - **Not stamped by this PR.** The list read (`GET /meta/view`), `/layers`, and the runtime HTTP dispatcher's `/meta` item read. The dispatcher serves no `/forms/*` door, so a dispatcher-only composition has no intake that could be unavailable. - **CRM.** `app-crm`'s lead form (`/forms/contact-us`) is the same class on a walled CRM deployment. Not booted here. - **Console.** Whether the console renders `_diagnostics.warnings`: NOT MEASURED (no `objectui` checkout in this container). --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc645f2 commit a7ab047

5 files changed

Lines changed: 620 additions & 42 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Public forms on a walled tenancy posture: a form whose object is walled by an organization column is no longer offered to anonymous visitors. An anonymous submission carries no organization, and on a walled posture an insert into such an object without one is refused, so the form used to render and then answer `500 ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on every submit. Both anonymous form endpoints (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer it exactly as they answer a withdrawn form (`404 FORM_NOT_FOUND`), so an anonymous caller learns nothing about the deployment's tenancy. The administrator's read of the form (`GET /meta/view/:name`) states why in `_diagnostics.warnings`, located at the form's `sharing`, with the remedy: if the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it. Forms bound to tenancy-disabled objects, and single-posture deployments, are unchanged.
6+
7+
Clause-②: no

‎packages/qa/dogfood/test/public-form-withdrawal-walled.dogfood.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ describe('walled posture: withdrawing a public form from anonymous intake', () =
162162
const p = await probe();
163163
expect([p.get, p.submit]).toEqual([200, 201]);
164164
expect(p.landed).toHaveLength(1);
165+
// [#21476] The control of `showcase-public-form-walled-intake.dogfood.test.ts`:
166+
// a tenancy-disabled object takes intake on a walled posture, so the
167+
// administrator's read states no intake reason.
168+
expect((await read())._diagnostics?.warnings).toBeUndefined();
165169
});
166170

167171
it('withdrawn in an organization: refused 403 NOT_OVERRIDABLE naming the env-wide save, and nothing is saved', async () => {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21476] On a WALLED tenancy posture the showcase's public contact form is
4+
// not offered, on a real boot.
5+
//
6+
// `showcase_inquiry.contact` publishes `/forms/contact-us`, and
7+
// `showcase_inquiry` is walled by the injected `organization_id`. An anonymous
8+
// submission carries no organization, and on a walled posture the engine
9+
// refuses an insert without one into a walled object. Before this pin the form
10+
// was served (`GET` 200) and every submit answered `500
11+
// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Pinned:
12+
//
13+
// - both anonymous doors answer the not-found answer a withdrawn form gets,
14+
// byte for byte (measured against the same form withdrawn env-wide on the
15+
// same boot), and no `showcase_inquiry` row lands;
16+
// - the administrator's read of the form names why, at `config.sharing`.
17+
//
18+
// The control (a form bound to a `tenancy: { enabled: false }` object on the
19+
// same walled posture is accepted, and its admin read carries no warning) is
20+
// `public-form-withdrawal-walled.dogfood.test.ts`.
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import showcaseStack from '@objectstack/example-showcase';
24+
import { bootStack, type VerifyStack } from '@objectstack/verify';
25+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
26+
27+
const VIEW = '/meta/view/showcase_inquiry.contact';
28+
const SYS = { isSystem: true } as const;
29+
30+
describe('showcase, walled posture: the public contact form is not offered, and the admin read says why', () => {
31+
let stack: VerifyStack;
32+
let admin: string;
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
let ql: any;
35+
let probeSeq = 0;
36+
37+
/** Both anonymous doors' raw answers, plus the rows a submit with a unique marker left. */
38+
const probe = async () => {
39+
const marker = `walled_intake_probe_${++probeSeq}`;
40+
const get = await stack.api('/forms/contact-us');
41+
const submit = await stack.api('/forms/contact-us/submit', {
42+
method: 'POST',
43+
headers: { 'content-type': 'application/json' },
44+
body: JSON.stringify({ name: marker, email: 'probe@example.com', message: 'probe' }),
45+
});
46+
const answers = [get.status, await get.text(), submit.status, await submit.text()];
47+
const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS });
48+
return { answers, landed: landed as unknown[] };
49+
};
50+
51+
/** The form as the administrator reads it. */
52+
const read = async (): Promise<Record<string, any>> => {
53+
const res = await stack.apiAs(admin, 'GET', VIEW);
54+
expect(res.status).toBe(200);
55+
const json = (await res.json()) as { item?: Record<string, any> };
56+
return (json.item ?? json) as Record<string, any>;
57+
};
58+
59+
/** Save the form env-wide (the admin has no active organization) with `allowAnonymous` set. */
60+
const saveAllowAnonymous = async (published: Record<string, any>, allowAnonymous: boolean) => {
61+
const body = structuredClone(published);
62+
body.config.sharing.allowAnonymous = allowAnonymous;
63+
const res = await stack.apiAs(admin, 'PUT', VIEW, body);
64+
expect(res.status, await res.clone().text()).toBe(200);
65+
};
66+
67+
beforeAll(async () => {
68+
stack = await bootStack(showcaseStack, {
69+
multiTenant: 'posture-only',
70+
security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }),
71+
});
72+
admin = await stack.signIn();
73+
ql = await stack.kernel.getServiceAsync('objectql');
74+
}, 180_000);
75+
76+
afterAll(async () => {
77+
await stack?.stop();
78+
});
79+
80+
it('PRECONDITION: a walled posture in force, no organization for an anonymous request, the form published', async () => {
81+
const tenancy = stack.tenancy();
82+
expect(tenancy.posture).toBe('isolated');
83+
expect(await tenancy.defaultOrgId()).toBeNull();
84+
expect((await read()).config?.sharing).toMatchObject({ enabled: true, allowAnonymous: true });
85+
});
86+
87+
it('both doors answer the withdrawn form\'s not-found answer byte for byte, and nothing lands', async () => {
88+
const unavailable = await probe();
89+
expect(unavailable.answers[0]).toBe(404);
90+
expect(JSON.parse(unavailable.answers[1] as string).code).toBe('FORM_NOT_FOUND');
91+
expect(unavailable.landed).toHaveLength(0);
92+
93+
const published = Object.fromEntries(Object.entries(await read()).filter(([k]) => !k.startsWith('_')));
94+
await saveAllowAnonymous(published, false);
95+
try {
96+
const withdrawn = await probe();
97+
expect(withdrawn.landed).toHaveLength(0);
98+
expect(unavailable.answers).toEqual(withdrawn.answers);
99+
} finally {
100+
await saveAllowAnonymous(published, true);
101+
}
102+
// Republished, it is still not offered on this posture.
103+
const again = await probe();
104+
expect(again.answers).toEqual(unavailable.answers);
105+
expect(again.landed).toHaveLength(0);
106+
});
107+
108+
it('the administrator\'s read names why, located at the form\'s sharing', async () => {
109+
const warnings = ((await read())._diagnostics?.warnings ?? []) as Array<{ path: string; message: string }>;
110+
expect(warnings).toHaveLength(1);
111+
expect(warnings[0].path).toBe('config.sharing');
112+
for (const named of ['/forms/contact-us', "'showcase_inquiry'", "'organization_id'", "'isolated'"]) {
113+
expect(warnings[0].message).toContain(named);
114+
}
115+
});
116+
});
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21476] One predicate decides whether an open public form can take an
4+
// anonymous submission on this deployment, and every door that serves the form
5+
// reads it. On a walled posture a form bound to an object walled by an
6+
// organization column used to be served and then answer `500
7+
// ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` on every submit. Pinned: both doors
8+
// answer the withdrawn form's answer byte for byte and nothing is written; every
9+
// `/forms/` route is one of those doors; the controls are accepted; and the
10+
// administrator's read (both arms) names the reason, inside the validator.
11+
12+
import { describe, it, expect, vi } from 'vitest';
13+
import { RestServer } from './rest-server';
14+
15+
// [#10126] Pay the first transform of these dist-resolved workspace deps at
16+
// MODULE LOAD rather than inside a clocked `it()` body.
17+
import '@objectstack/spec/ui';
18+
19+
const ORG = 'org_alpha';
20+
const SLUG = 'contact-us';
21+
22+
function mockServer() {
23+
return {
24+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
25+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
26+
};
27+
}
28+
29+
function mockRes() {
30+
const res: any = { statusCode: 200, body: undefined, headers: {} as Record<string, string> };
31+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
32+
res.json = vi.fn((b: any) => { res.body = b; return res; });
33+
res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; });
34+
res.send = vi.fn(() => res);
35+
res.end = vi.fn(() => res);
36+
return res;
37+
}
38+
39+
/** A flattened `viewKind: 'form'` item, as the protocol serves it. */
40+
const formView = (allowAnonymous = true) => ({
41+
name: 'contact', object: 'inquiry', viewKind: 'form', _diagnostics: { valid: true },
42+
config: {
43+
data: { object: 'inquiry' },
44+
sections: [{ fields: ['name', 'email'] }],
45+
sharing: { enabled: true, allowAnonymous, publicLink: `/forms/${SLUG}` },
46+
},
47+
});
48+
49+
/** The bound object as the doors read it: the registry injects `organization_id`. */
50+
const inquiryObject = (tenancyDisabled: boolean) => ({
51+
name: 'inquiry', label: 'Inquiry', ...(tenancyDisabled ? { tenancy: { enabled: false } } : {}),
52+
fields: {
53+
organization_id: { type: 'lookup', reference: 'sys_organization' },
54+
name: { type: 'text', label: 'Name' },
55+
email: { type: 'text', label: 'Email' },
56+
},
57+
});
58+
59+
/** Reproduces the registry's own "never registered" rejection. */
60+
const notRegistered = (): Error => Object.assign(new Error("Service 'tenancy' not found"), {
61+
__objectstackServiceNotRegistered: true, code: 'SERVICE_NOT_REGISTERED', serviceName: 'tenancy',
62+
});
63+
64+
type Tenancy = 'isolated' | 'group' | 'degraded' | 'single' | 'not-registered';
65+
66+
/** `tenancyDisabled`: the control object (ADR-0066); `allowAnonymous: false`: the withdrawn reference; `cached`: the default admin-read arm. */
67+
interface Setup { tenancy: Tenancy; tenancyDisabled?: boolean; allowAnonymous?: boolean; cached?: boolean }
68+
69+
function build(setup: Setup) {
70+
const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} });
71+
const getMetaItems = vi.fn(async (req: { type: string }) => {
72+
if (req.type === 'view') return [formView(setup.allowAnonymous ?? true)];
73+
if (req.type === 'object') return [inquiryObject(setup.tenancyDisabled ?? false)];
74+
return [];
75+
});
76+
const getMetaItemCached = vi.fn(async (_req: { cacheRequest: { ifNoneMatch?: string } }) => ({
77+
data: formView(setup.allowAnonymous ?? true), etag: { value: 'v1', weak: false }, notModified: false,
78+
}));
79+
const protocol: any = {
80+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
81+
getMetaTypes: vi.fn().mockResolvedValue([]),
82+
getMetaItems,
83+
getMetaItem: vi.fn(async ({ type, name }: any) => ({ type, name, item: formView(setup.allowAnonymous ?? true) })),
84+
getMetaItemCached: setup.cached ? getMetaItemCached : undefined,
85+
createData,
86+
};
87+
const tenancyServiceProvider = async () => {
88+
switch (setup.tenancy) {
89+
case 'isolated': return { posture: 'isolated', requestedPosture: 'isolated', defaultOrgId: async () => null };
90+
case 'group': return { posture: 'group', requestedPosture: 'group', defaultOrgId: async () => null };
91+
// A walled request the deployment cannot enforce: in force it is `single`.
92+
case 'degraded': return { posture: 'single', requestedPosture: 'isolated', defaultOrgId: async () => null };
93+
case 'single': return { posture: 'single', requestedPosture: 'single', defaultOrgId: async () => ORG };
94+
case 'not-registered': throw notRegistered();
95+
}
96+
};
97+
const rest = new RestServer(
98+
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
99+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
100+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
101+
tenancyServiceProvider,
102+
);
103+
(rest as any).resolveExecCtx = async () => ({ userId: 'admin', systemPermissions: ['manage_metadata'] });
104+
rest.registerRoutes();
105+
const routes = rest.getRoutes();
106+
const find = (method: string, path: string) => routes.find((r) => r.method === method && r.path === path)!;
107+
const formDoors = routes.filter((r) => r.path.includes('/forms/'));
108+
const drive = async (route: { handler: (req: any, res: any) => any }, method: string) => {
109+
const res = mockRes();
110+
const body = method === 'POST' ? { body: { name: 'x', email: 'x@example.com' } } : {};
111+
await route.handler({ params: { slug: SLUG }, query: {}, headers: {}, ...body } as any, res);
112+
return res;
113+
};
114+
return {
115+
createData, getMetaItems, getMetaItemCached, formDoors, drive,
116+
get: () => drive(find('GET', '/api/v1/forms/:slug'), 'GET'),
117+
post: () => drive(find('POST', '/api/v1/forms/:slug/submit'), 'POST'),
118+
async adminRead(headers: Record<string, string> = {}) {
119+
const res = mockRes();
120+
await find('GET', '/api/v1/meta/:type/:name').handler({ params: { type: 'view', name: 'contact' }, query: {}, headers } as any, res);
121+
return res;
122+
},
123+
};
124+
}
125+
126+
/** What the withdrawn form answers on a door — the shape an unavailable form must match byte for byte. */
127+
async function withdrawnAnswer(door: 'get' | 'post'): Promise<[number, string]> {
128+
const s = build({ tenancy: 'isolated', allowAnonymous: false });
129+
const res = await s[door]();
130+
return [res.statusCode, JSON.stringify(res.body)];
131+
}
132+
133+
const answer = (res: any): [number, string] => [res.statusCode, JSON.stringify(res.body)];
134+
135+
describe('[#21476] a public form that cannot take intake on this posture is not offered', () => {
136+
it('REFERENCE: the withdrawn form answers 404 FORM_NOT_FOUND on both doors', async () => {
137+
const [getStatus, getBody] = await withdrawnAnswer('get');
138+
const [postStatus, postBody] = await withdrawnAnswer('post');
139+
expect([getStatus, JSON.parse(getBody).code]).toEqual([404, 'FORM_NOT_FOUND']);
140+
expect([postStatus, JSON.parse(postBody).code]).toEqual([404, 'FORM_NOT_FOUND']);
141+
});
142+
143+
// ENUMERATION: the doors are read off the registered routes, not listed by
144+
// hand, and each door × walled posture is its own row.
145+
const doors = build({ tenancy: 'isolated' }).formDoors;
146+
it('ENUMERATION: the routes under /forms/ are exactly the two anonymous form doors', () => {
147+
expect(doors.map((r) => `${r.method} ${r.path}`).sort())
148+
.toEqual(['GET /api/v1/forms/:slug', 'POST /api/v1/forms/:slug/submit']);
149+
});
150+
for (const door of doors) {
151+
for (const posture of ['isolated', 'group'] as const) {
152+
it(`${door.method} ${door.path} · '${posture}', walled object: the withdrawn form's answer byte for byte, nothing written`, async () => {
153+
const s = build({ tenancy: posture });
154+
const route = s.formDoors.find((r) => r.method === door.method && r.path === door.path)!;
155+
const expected = await withdrawnAnswer(door.method === 'POST' ? 'post' : 'get');
156+
expect(answer(await s.drive(route, door.method))).toEqual(expected);
157+
expect(s.createData).not.toHaveBeenCalled();
158+
});
159+
}
160+
}
161+
162+
it('CONTROL: walled posture, object declared tenancy: { enabled: false } — accepted on both doors', async () => {
163+
const s = build({ tenancy: 'isolated', tenancyDisabled: true });
164+
const get = await s.get();
165+
expect(get.statusCode).toBe(200);
166+
expect(get.body.object).toBe('inquiry');
167+
expect((await s.post()).statusCode).toBe(201);
168+
expect(s.createData).toHaveBeenCalledTimes(1);
169+
});
170+
171+
it('CONTROL: single posture, walled object — accepted, and the object is not even read for the predicate', async () => {
172+
const s = build({ tenancy: 'single' });
173+
expect((await s.post()).statusCode).toBe(201);
174+
expect(s.getMetaItems.mock.calls.map(([r]) => r.type)).toEqual(['view']);
175+
});
176+
177+
it('CONTROL: a degraded walled request reads the posture IN FORCE (single) — accepted', async () => {
178+
const s = build({ tenancy: 'degraded' });
179+
expect((await s.get()).statusCode).toBe(200);
180+
expect((await s.post()).statusCode).toBe(201);
181+
});
182+
183+
it('CONTROL: no tenancy service registered — no wall the doors can read, accepted', async () => {
184+
const s = build({ tenancy: 'not-registered' });
185+
expect((await s.get()).statusCode).toBe(200);
186+
expect((await s.post()).statusCode).toBe(201);
187+
});
188+
});
189+
190+
describe('[#21476] the administrator\'s read names why intake is unavailable', () => {
191+
for (const cached of [false, true]) {
192+
const arm = cached ? 'cached arm' : 'uncached arm';
193+
194+
it(`${arm}: walled posture, walled object — a warning located at the form's sharing, naming the reason`, async () => {
195+
const s = build({ tenancy: 'isolated', cached });
196+
const res = await s.adminRead();
197+
expect(res.statusCode).toBe(200);
198+
const diagnostics = res.body.item._diagnostics;
199+
expect(diagnostics.valid).toBe(true);
200+
expect(diagnostics.warnings).toHaveLength(1);
201+
expect(diagnostics.warnings[0].path).toBe('config.sharing');
202+
const message: string = diagnostics.warnings[0].message;
203+
for (const named of [`/forms/${SLUG}`, "'inquiry'", "'organization_id'", "'isolated'", 'tenancy: { enabled: false }']) {
204+
expect(message).toContain(named);
205+
}
206+
});
207+
208+
it(`${arm}: CONTROL — tenancy-disabled object or single posture, no warning and _diagnostics untouched`, async () => {
209+
for (const setup of [{ tenancy: 'isolated', tenancyDisabled: true }, { tenancy: 'single' }] as const) {
210+
const res = await build({ ...setup, cached }).adminRead();
211+
expect(res.statusCode).toBe(200);
212+
expect(res.body.item._diagnostics).toEqual({ valid: true });
213+
}
214+
});
215+
}
216+
217+
it('cached arm: the reason enters the validator — the bare protocol ETag revalidates into the reason, the folded one is 304', async () => {
218+
const s = build({ tenancy: 'isolated', cached: true });
219+
const first = await s.adminRead();
220+
const etag = first.headers.ETag;
221+
expect(etag).toMatch(/^"v1~[0-9a-f]{8}"$/);
222+
expect(s.getMetaItemCached).toHaveBeenCalledTimes(1);
223+
expect(s.getMetaItemCached.mock.calls[0]?.[0].cacheRequest).toEqual({ ifNoneMatch: undefined, ifModifiedSince: undefined });
224+
225+
const stale = await s.adminRead({ 'if-none-match': '"v1"' });
226+
expect(stale.statusCode).toBe(200);
227+
expect(stale.body.item._diagnostics.warnings).toHaveLength(1);
228+
229+
const fresh = await s.adminRead({ 'if-none-match': etag });
230+
expect(fresh.statusCode).toBe(304);
231+
});
232+
233+
it('cached arm: CONTROL — with no reason the validator is the protocol\'s own, and it still answers 304', async () => {
234+
const s = build({ tenancy: 'isolated', tenancyDisabled: true, cached: true });
235+
const first = await s.adminRead();
236+
expect(first.headers.ETag).toBe('"v1"');
237+
expect((await s.adminRead({ 'if-none-match': '"v1"' })).statusCode).toBe(304);
238+
});
239+
});

0 commit comments

Comments
 (0)