Skip to content

Commit 2d2204d

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21457-barrel-import-collect-phase
2 parents 8e8b3bb + 49524f6 commit 2d2204d

4 files changed

Lines changed: 407 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Withdrawing a public form from anonymous intake now takes effect on every intake door
6+
7+
Clause-②: no
8+
9+
When an administrator withdraws a public form, both anonymous form routes (`GET /forms/:slug` and `POST /forms/:slug/submit`) now answer `404 FORM_NOT_FOUND` and no record is created. Republishing the form restores both routes. If a service the routes need to resolve the form is registered but cannot be reached, both routes refuse the request instead of serving the form.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21331] Withdrawing a public form from anonymous intake takes effect on
4+
// every intake door, on a real showcase boot.
5+
//
6+
// The showcase ships `showcase_inquiry.contact`, a FormView with
7+
// `sharing.allowAnonymous: true` at `/forms/contact-us`
8+
// (`showcase-public-form.dogfood.test.ts` pins that it serves). An
9+
// administrator withdraws it the way the editor does, with `PUT /meta/view/...`
10+
// at their own session, once env-wide (no active organization) and once in
11+
// their organization (`orgContext: true` gives the admin one). After either
12+
// withdrawal both anonymous doors must refuse, and nothing may land:
13+
//
14+
// - `GET /forms/contact-us` and `POST /forms/contact-us/submit` both answer
15+
// `404 FORM_NOT_FOUND`;
16+
// - no `showcase_inquiry` row is written by the refused submit.
17+
//
18+
// Both sides are pinned: republishing at the same scope restores both doors,
19+
// and after the organization republish the row lands in that organization.
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import showcaseStack from '@objectstack/example-showcase';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
25+
26+
const VIEW = '/meta/view/showcase_inquiry.contact';
27+
const SYS = { isSystem: true } as const;
28+
29+
interface Probe {
30+
get: number;
31+
getCode: unknown;
32+
submit: number;
33+
submitCode: unknown;
34+
landed: Array<{ organization_id?: unknown }>;
35+
}
36+
37+
describe('showcase: withdrawing the public contact form closes every intake door', () => {
38+
let stack: VerifyStack;
39+
let admin: string;
40+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
41+
let ql: any;
42+
let published: Record<string, any>;
43+
let probeSeq = 0;
44+
45+
/** Both anonymous doors, plus the rows a submit with a unique marker left. */
46+
const probe = async (): Promise<Probe> => {
47+
const marker = `withdrawal_probe_${++probeSeq}`;
48+
const get = await stack.api('/forms/contact-us');
49+
const getBody = (await get.json()) as { code?: unknown };
50+
const submit = await stack.api('/forms/contact-us/submit', {
51+
method: 'POST',
52+
headers: { 'content-type': 'application/json' },
53+
body: JSON.stringify({ name: marker, email: 'probe@example.com', message: 'probe' }),
54+
});
55+
const submitBody = (await submit.json()) as { code?: unknown };
56+
const landed = await ql.find('showcase_inquiry', { where: { name: marker }, context: SYS });
57+
return { get: get.status, getCode: getBody.code, submit: submit.status, submitCode: submitBody.code, landed };
58+
};
59+
60+
/** Save the form with `allowAnonymous` set, at the admin's current session scope. */
61+
const save = async (allowAnonymous: boolean): Promise<string> => {
62+
const body = structuredClone(published);
63+
body.config.sharing.allowAnonymous = allowAnonymous;
64+
const res = await stack.apiAs(admin, 'PUT', VIEW, body);
65+
const json = (await res.json()) as { message?: string };
66+
expect(res.status, JSON.stringify(json)).toBe(200);
67+
return String(json.message ?? '');
68+
};
69+
70+
beforeAll(async () => {
71+
stack = await bootStack(showcaseStack, {
72+
orgContext: true,
73+
security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }),
74+
});
75+
admin = await stack.signIn();
76+
ql = await stack.kernel.getServiceAsync('objectql');
77+
const res = await stack.apiAs(admin, 'GET', VIEW);
78+
expect(res.status).toBe(200);
79+
const json = (await res.json()) as { item?: Record<string, any> };
80+
const item = (json.item ?? json) as Record<string, any>;
81+
// The editor's PUT body: the effective item without its read decorations.
82+
published = Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_')));
83+
expect(published.config?.sharing?.allowAnonymous).toBe(true);
84+
}, 120_000);
85+
86+
afterAll(async () => {
87+
await stack?.stop();
88+
});
89+
90+
it('PRECONDITION: the published form accepts anonymous intake', async () => {
91+
const p = await probe();
92+
expect(p.get).toBe(200);
93+
expect(p.submit).toBe(201);
94+
expect(p.landed).toHaveLength(1);
95+
});
96+
97+
it('withdrawn env-wide: both doors answer 404 FORM_NOT_FOUND and nothing lands; republishing restores them', async () => {
98+
const off = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: null });
99+
expect(off.status).toBe(200);
100+
expect(await save(false)).toMatch(/env-wide/);
101+
const closed = await probe();
102+
expect([closed.get, closed.getCode, closed.submit, closed.submitCode])
103+
.toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']);
104+
expect(closed.landed).toHaveLength(0);
105+
106+
expect(await save(true)).toMatch(/env-wide/);
107+
const open = await probe();
108+
expect([open.get, open.submit]).toEqual([200, 201]);
109+
expect(open.landed).toHaveLength(1);
110+
});
111+
112+
it('withdrawn in the admin\'s organization: both doors answer 404 FORM_NOT_FOUND and nothing lands', async () => {
113+
const orgs = await ql.find('sys_organization', { fields: ['id'], limit: 2, context: SYS });
114+
expect(orgs, 'the showcase boot holds exactly one organization').toHaveLength(1);
115+
const on = await stack.apiAs(admin, 'POST', '/auth/organization/set-active', { organizationId: orgs[0].id });
116+
expect(on.status).toBe(200);
117+
118+
expect(await save(false), 'the save is an organization overlay').toContain(`org=${orgs[0].id}`);
119+
const p = await probe();
120+
expect([p.get, p.getCode, p.submit, p.submitCode]).toEqual([404, 'FORM_NOT_FOUND', 404, 'FORM_NOT_FOUND']);
121+
expect(p.landed).toHaveLength(0);
122+
});
123+
124+
it('republished in the same organization (control): both doors accept and the row lands in that organization', async () => {
125+
const message = await save(true);
126+
const org = /org=(\S+?),/.exec(message)?.[1];
127+
expect(org, message).toBeTruthy();
128+
const p = await probe();
129+
expect([p.get, p.submit]).toEqual([200, 201]);
130+
expect(p.landed).toHaveLength(1);
131+
expect(p.landed[0].organization_id).toBe(org);
132+
});
133+
});
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21331] Withdrawing a public form from anonymous intake takes effect on
4+
// every intake door. Both doors (`GET /forms/:slug` and
5+
// `POST /forms/:slug/submit`) read the form in the organization the request
6+
// belongs to: the tenancy service's `defaultOrgId()`. That is the same
7+
// organization whose overlay the administrator's editor shows. A withdrawal
8+
// saved in that organization therefore closes both doors, and nothing is
9+
// written.
10+
//
11+
// Pinned both sides:
12+
// - withdrawn in the organization: both doors answer 404 FORM_NOT_FOUND and
13+
// `createData` is never called;
14+
// - published in the organization (the control): both doors accept, and
15+
// every metadata read the doors make names that organization;
16+
// - withdrawn env-wide with no organization to resolve: both doors refuse;
17+
// - tenancy never registered: the env-wide read, unchanged;
18+
// - tenancy registered but unreachable: both doors refuse (fail closed).
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { RestServer } from './rest-server';
22+
23+
// [#10126] Pay the first transform of these dist-resolved workspace deps at
24+
// MODULE LOAD rather than inside a clocked `it()` body.
25+
import '@objectstack/spec/ui';
26+
27+
const ORG = 'org_alpha';
28+
29+
function mockServer() {
30+
return {
31+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
32+
use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
33+
};
34+
}
35+
36+
function mockRes() {
37+
const res: any = { statusCode: 200, body: undefined };
38+
res.status = vi.fn((c: number) => { res.statusCode = c; return res; });
39+
res.json = vi.fn((b: any) => { res.body = b; return res; });
40+
res.header = vi.fn(() => res);
41+
res.end = vi.fn(() => res);
42+
return res;
43+
}
44+
45+
function formView(allowAnonymous: boolean) {
46+
return {
47+
name: 'contact',
48+
object: 'inquiry',
49+
viewKind: 'form',
50+
config: {
51+
data: { object: 'inquiry' },
52+
sections: [{ fields: ['name', 'email'] }],
53+
sharing: { allowAnonymous, publicLink: '/forms/contact-us' },
54+
},
55+
};
56+
}
57+
58+
const inquiryObject = {
59+
name: 'inquiry',
60+
label: 'Inquiry',
61+
fields: {
62+
name: { type: 'text', label: 'Name' },
63+
email: { type: 'text', label: 'Email' },
64+
},
65+
};
66+
67+
/** Reproduces the registry's own "never registered" rejection. */
68+
function notRegistered(): Error {
69+
return Object.assign(new Error("Service 'tenancy' not found"), {
70+
__objectstackServiceNotRegistered: true,
71+
code: 'SERVICE_NOT_REGISTERED',
72+
serviceName: 'tenancy',
73+
});
74+
}
75+
76+
interface Setup {
77+
/** What the env-wide (organization-less) read answers. */
78+
envWide: boolean;
79+
/** What the organization read answers; `undefined` = no overlay there. */
80+
inOrg?: boolean;
81+
/** The tenancy provider's behaviour. */
82+
tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable';
83+
}
84+
85+
function build(setup: Setup) {
86+
const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} });
87+
const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string }) => {
88+
if (req.type === 'view') {
89+
const effective = req.organizationId === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide;
90+
return [formView(effective)];
91+
}
92+
if (req.type === 'object') return [inquiryObject];
93+
return [];
94+
});
95+
const protocol: any = {
96+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '' } }),
97+
getMetaTypes: vi.fn().mockResolvedValue([]),
98+
getMetaItems,
99+
createData,
100+
};
101+
const tenancyServiceProvider = async () => {
102+
switch (setup.tenancy) {
103+
case 'org': return { posture: 'single', defaultOrgId: async () => ORG };
104+
case 'no-org': return { posture: 'single', defaultOrgId: async () => null };
105+
case 'not-registered': throw notRegistered();
106+
case 'unreachable': throw new Error('tenancy service failed to construct');
107+
}
108+
};
109+
const rest = new RestServer(
110+
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
111+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
112+
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
113+
tenancyServiceProvider,
114+
);
115+
rest.registerRoutes();
116+
const find = (method: string, suffix: string) =>
117+
rest.getRoutes().find((r) => r.method === method && r.path.endsWith(suffix))!;
118+
const resolve = find('GET', '/forms/:slug');
119+
const submit = find('POST', '/forms/:slug/submit');
120+
return {
121+
createData,
122+
getMetaItems,
123+
async get() {
124+
const res = mockRes();
125+
await resolve.handler({ params: { slug: 'contact-us' }, query: {}, headers: {} } as any, res);
126+
return res;
127+
},
128+
async post() {
129+
const res = mockRes();
130+
await submit.handler(
131+
{ params: { slug: 'contact-us' }, query: {}, headers: {}, body: { name: 'x', email: 'x@example.com' } } as any,
132+
res,
133+
);
134+
return res;
135+
},
136+
};
137+
}
138+
139+
describe('[#21331] public form withdrawal reaches every intake door', () => {
140+
it('withdrawn in the organization: both doors answer FORM_NOT_FOUND and nothing is written', async () => {
141+
const s = build({ envWide: true, inOrg: false, tenancy: 'org' });
142+
const get = await s.get();
143+
expect(get.statusCode).toBe(404);
144+
expect(get.body.code).toBe('FORM_NOT_FOUND');
145+
const post = await s.post();
146+
expect(post.statusCode).toBe(404);
147+
expect(post.body.code).toBe('FORM_NOT_FOUND');
148+
expect(s.createData).not.toHaveBeenCalled();
149+
});
150+
151+
it('published in the organization (control): both doors accept, every read names the organization', async () => {
152+
const s = build({ envWide: true, inOrg: true, tenancy: 'org' });
153+
const get = await s.get();
154+
expect(get.statusCode).toBe(200);
155+
expect(get.body.object).toBe('inquiry');
156+
expect(Object.keys(get.body.objectSchema.fields).sort()).toEqual(['email', 'name']);
157+
const post = await s.post();
158+
expect(post.statusCode).toBe(201);
159+
expect(s.createData).toHaveBeenCalledTimes(1);
160+
const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.organizationId]);
161+
expect(reads.length).toBeGreaterThan(0);
162+
for (const [, organizationId] of reads) expect(organizationId).toBe(ORG);
163+
});
164+
165+
it('an organization overlay that publishes a form the package withdrew is honoured too', async () => {
166+
const s = build({ envWide: false, inOrg: true, tenancy: 'org' });
167+
expect((await s.get()).statusCode).toBe(200);
168+
expect((await s.post()).statusCode).toBe(201);
169+
});
170+
171+
it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => {
172+
const s = build({ envWide: false, tenancy: 'no-org' });
173+
expect((await s.get()).body.code).toBe('FORM_NOT_FOUND');
174+
expect((await s.post()).body.code).toBe('FORM_NOT_FOUND');
175+
expect(s.createData).not.toHaveBeenCalled();
176+
for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined();
177+
});
178+
179+
it('tenancy never registered: the env-wide read, unchanged', async () => {
180+
const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' });
181+
expect((await s.get()).statusCode).toBe(200);
182+
expect((await s.post()).statusCode).toBe(201);
183+
for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined();
184+
});
185+
186+
it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => {
187+
const s = build({ envWide: true, tenancy: 'unreachable' });
188+
const get = await s.get();
189+
expect(get.statusCode).toBe(500);
190+
expect(get.body.code).toBe('FORM_RESOLVE_FAILED');
191+
const post = await s.post();
192+
expect(post.statusCode).toBe(503);
193+
expect(post.body.code).toBe('SERVICE_UNAVAILABLE');
194+
expect(s.createData).not.toHaveBeenCalled();
195+
expect(s.getMetaItems).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'view' }));
196+
});
197+
});

0 commit comments

Comments
 (0)