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