|
| 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