|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#9967] A sandboxed hook body that DECLARES its own HTTP status is served |
| 4 | +// with it on `/api/v1/data` — the sandbox unwrap no longer outranks the |
| 5 | +// declared-status read. |
| 6 | +// |
| 7 | +// --------------------------------------------------------------------------- |
| 8 | +// The asymmetry this file closes: since #7867 the QuickJS side-channel carries |
| 9 | +// a body-thrown error's declared `status` out of the VM onto |
| 10 | +// `SandboxError.status`, and `domains/actions.ts` honours it ("an error that |
| 11 | +// NAMES its own HTTP status is asking to be served with it"). On the CRUD data |
| 12 | +// routes the sandbox-unwrap branch of `mapDataError` sat ABOVE the |
| 13 | +// `declaredHttpStatus` passthrough and answered `{ status: 400 }` |
| 14 | +// unconditionally, so a hook body's deliberate |
| 15 | +// |
| 16 | +// var e = new Error('close-period lock'); e.status = 403; throw e; |
| 17 | +// |
| 18 | +// crossed the VM fine and was then answered 400 — a permission refusal |
| 19 | +// presented as a client-input error, the #7525/#8016 door-disagreement shape |
| 20 | +// one branch earlier. |
| 21 | +// |
| 22 | +// What is deliberately UNCHANGED, pinned in §3 here and (independently) by |
| 23 | +// `hook-error-format.dogfood.test.ts` and |
| 24 | +// `rest-hook-refusal-status-passthrough.test.ts` §3: |
| 25 | +// - a body throw that declares NO status keeps the verbatim-message 400; |
| 26 | +// - a body that CRASHES (`isScriptFaultMessage`) stays the sanitised 500 — |
| 27 | +// even when the crash object carries a stray `status`; |
| 28 | +// - the envelope still carries NO `code` field (old @objectstack/client |
| 29 | +// builds prepend `code` to the human-readable message). |
| 30 | +// |
| 31 | +// Reverse verification (measured against this branch with ONLY |
| 32 | +// `error-response.ts` reverted to the pre-fix `origin/main` copy — the fix |
| 33 | +// committed first, the revert via `git checkout origin/main -- <path>`, the |
| 34 | +// restore via `git checkout <branch> -- <path>`): predicted §1 + §2 + §4 red, |
| 35 | +// §3 green by construction. The measured result is recorded in the PR body |
| 36 | +// rather than here so a wrong prediction cannot be rewritten to fit. |
| 37 | +// --------------------------------------------------------------------------- |
| 38 | + |
| 39 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 40 | +import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; |
| 41 | +import { mapDataError, RestServer } from './rest-server.js'; |
| 42 | + |
| 43 | +const DATA_ITEM = '/api/v1/data/:object/:id'; |
| 44 | + |
| 45 | +// --------------------------------------------------------------------------- |
| 46 | +// Fixtures — the shape `quickjs-runner.ts` actually produces: `.message` is |
| 47 | +// the `<kind> '<name>' threw: <msg>` debug wrapper, `.innerMessage` the |
| 48 | +// business text, `.status` the #7867 side-channel value. Reproduced here so |
| 49 | +// `@objectstack/rest` does not depend on `@objectstack/runtime` to run its |
| 50 | +// own tests. |
| 51 | +// --------------------------------------------------------------------------- |
| 52 | + |
| 53 | +/** The issue's own repro: a body refusal that NAMES its status. */ |
| 54 | +function sandboxRefusal(overrides: Record<string, unknown> = {}) { |
| 55 | + const err: any = new Error("hook 'close_period_guard' threw: Error: close-period lock"); |
| 56 | + err.name = 'SandboxError'; |
| 57 | + err.innerMessage = 'close-period lock'; |
| 58 | + return Object.assign(err, overrides); |
| 59 | +} |
| 60 | + |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | +// §1 The mapping itself — declared 4xx |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | + |
| 65 | +describe('[#9967] mapDataError: a sandboxed body that declares a 4xx status keeps it', () => { |
| 66 | + it('the reported shape: `e.status = 403` answers 403, not 400', () => { |
| 67 | + const r = mapDataError(sandboxRefusal({ status: 403 }), 'showcase_task'); |
| 68 | + |
| 69 | + expect(r.status).toBe(403); |
| 70 | + // The measured defect, pinned as a NEGATIVE so a partial fix cannot pass. |
| 71 | + expect(r.status).not.toBe(400); |
| 72 | + // "Keeping the unwrapped `innerMessage` as the body text": the business |
| 73 | + // message verbatim, never the debug wrapper. |
| 74 | + expect(r.body.error).toBe('close-period lock'); |
| 75 | + expect(JSON.stringify(r.body)).not.toMatch(/threw:|hook '/); |
| 76 | + expect(r.body.object).toBe('showcase_task'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('the body is byte-identical to the undeclared 400 envelope — only the status moves', () => { |
| 80 | + // The unwrap branch's own contract (deliberately NO `code`, `object` |
| 81 | + // rides) is unchanged by the fix; compared output-to-output so a field |
| 82 | + // later added to BOTH envelopes (e.g. #9934's marking) keeps this green. |
| 83 | + const declared = mapDataError(sandboxRefusal({ status: 403 }), 'showcase_task'); |
| 84 | + const undeclared = mapDataError(sandboxRefusal(), 'showcase_task'); |
| 85 | + |
| 86 | + expect(declared.body).toEqual(undeclared.body); |
| 87 | + expect(declared.body.code).toBeUndefined(); |
| 88 | + }); |
| 89 | + |
| 90 | + it('the whole client band is served, off either spelling — same read as every other exit', () => { |
| 91 | + for (const status of [401, 403, 404, 409, 423, 451]) { |
| 92 | + expect(mapDataError(sandboxRefusal({ status }), 'showcase_task').status).toBe(status); |
| 93 | + expect(mapDataError(sandboxRefusal({ statusCode: status }), 'showcase_task').status).toBe(status); |
| 94 | + } |
| 95 | + }); |
| 96 | + |
| 97 | + it('a numeric `status` wins over `statusCode` — precedence matches the passthrough', () => { |
| 98 | + const r = mapDataError(sandboxRefusal({ status: 409, statusCode: 403 }), 'showcase_task'); |
| 99 | + expect(r.status).toBe(409); |
| 100 | + }); |
| 101 | + |
| 102 | + it('an out-of-band status is not a declaration — the 400 default holds', () => { |
| 103 | + for (const status of [0, 200, 302, 399, 600, 999]) { |
| 104 | + const r = mapDataError(sandboxRefusal({ status }), 'showcase_task'); |
| 105 | + expect(r.status).toBe(400); |
| 106 | + expect(r.body.error).toBe('close-period lock'); |
| 107 | + } |
| 108 | + }); |
| 109 | +}); |
| 110 | + |
| 111 | +// --------------------------------------------------------------------------- |
| 112 | +// §2 Declared SERVER band — status kept, prose withheld (#5582's rule applies |
| 113 | +// to this producer exactly as to every other) |
| 114 | +// --------------------------------------------------------------------------- |
| 115 | + |
| 116 | +describe('[#9967] a sandboxed body that declares a 5xx takes the sanitised 5xx arm', () => { |
| 117 | + it('`e.status = 503` keeps the 503 and withholds the words', () => { |
| 118 | + const r = mapDataError(sandboxRefusal({ status: 503 }), 'showcase_task'); |
| 119 | + |
| 120 | + expect(r.status).toBe(503); |
| 121 | + expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); |
| 122 | + expect(JSON.stringify(r.body)).not.toContain('close-period lock'); |
| 123 | + // No code was declared; none is invented (ADR-0112: the producer names |
| 124 | + // the condition). |
| 125 | + expect(r.body.code).toBeUndefined(); |
| 126 | + }); |
| 127 | + |
| 128 | + it('a declared 5xx WITH a registered code ships both — same answer the passthrough gives', () => { |
| 129 | + const r = mapDataError( |
| 130 | + sandboxRefusal({ status: 503, code: 'SERVICE_UNAVAILABLE' }), |
| 131 | + 'showcase_task', |
| 132 | + ); |
| 133 | + expect(r.status).toBe(503); |
| 134 | + expect(r.body.code).toBe('SERVICE_UNAVAILABLE'); |
| 135 | + expect(r.body.error).toBe(INTERNAL_ERROR_MESSAGE); |
| 136 | + }); |
| 137 | +}); |
| 138 | + |
| 139 | +// --------------------------------------------------------------------------- |
| 140 | +// §3 What deliberately did NOT move — green by construction under the fix's |
| 141 | +// reverse verification |
| 142 | +// --------------------------------------------------------------------------- |
| 143 | + |
| 144 | +describe('[#9967] the pinned defaults are untouched', () => { |
| 145 | + it('an UNDECLARED body throw keeps the verbatim-message 400 with no code', () => { |
| 146 | + const r = mapDataError(sandboxRefusal(), 'showcase_task'); |
| 147 | + |
| 148 | + expect(r.status).toBe(400); |
| 149 | + expect(r.body.error).toBe('close-period lock'); |
| 150 | + expect(r.body.code).toBeUndefined(); |
| 151 | + }); |
| 152 | + |
| 153 | + it('a body CRASH stays the sanitised 500 even when it carries a stray `status`', () => { |
| 154 | + // Crash classification outranks the declaration: a `TypeError` that |
| 155 | + // happens to have a `status` property is still a script fault, never |
| 156 | + // an author-declared refusal. |
| 157 | + const err = sandboxRefusal({ status: 403 }); |
| 158 | + err.innerMessage = 'TypeError: boom'; |
| 159 | + const r = mapDataError(err, 'showcase_task'); |
| 160 | + |
| 161 | + expect(r.status).toBe(500); |
| 162 | + expect(r.body.code).toBe('INTERNAL_ERROR'); |
| 163 | + expect(JSON.stringify(r.body)).not.toMatch(/TypeError|boom/); |
| 164 | + }); |
| 165 | +}); |
| 166 | + |
| 167 | +// --------------------------------------------------------------------------- |
| 168 | +// §4 The wire — the reported request walked on the real CRUD data route |
| 169 | +// (mirrors `rest-hook-refusal-status-passthrough.test.ts` §2's harness) |
| 170 | +// --------------------------------------------------------------------------- |
| 171 | + |
| 172 | +function createMockServer() { |
| 173 | + return { |
| 174 | + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), |
| 175 | + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), |
| 176 | + }; |
| 177 | +} |
| 178 | + |
| 179 | +function makeRes() { |
| 180 | + const res: any = { statusCode: 200, body: undefined }; |
| 181 | + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); |
| 182 | + res.json = vi.fn((b: any) => { res.body = b; return res; }); |
| 183 | + res.header = vi.fn(() => res); |
| 184 | + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); |
| 185 | + return res; |
| 186 | +} |
| 187 | + |
| 188 | +function setup(protocolOverrides: Record<string, unknown> = {}) { |
| 189 | + const protocol: any = { |
| 190 | + getDiscovery: vi.fn().mockResolvedValue({ |
| 191 | + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, |
| 192 | + }), |
| 193 | + getMetaTypes: vi.fn().mockResolvedValue([]), |
| 194 | + getMetaItems: vi.fn().mockResolvedValue([{ name: 'showcase_task' }]), |
| 195 | + getMetaItem: vi.fn().mockResolvedValue({}), |
| 196 | + findData: vi.fn().mockResolvedValue([]), |
| 197 | + createData: vi.fn().mockResolvedValue({}), |
| 198 | + updateData: vi.fn().mockResolvedValue({}), |
| 199 | + ...protocolOverrides, |
| 200 | + }; |
| 201 | + const rest = new RestServer( |
| 202 | + createMockServer() as any, |
| 203 | + protocol, |
| 204 | + { api: { requireAuth: false } } as any, |
| 205 | + ); |
| 206 | + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); |
| 207 | + rest.registerRoutes(); |
| 208 | + return rest; |
| 209 | +} |
| 210 | + |
| 211 | +async function callPatch(rest: any, object: string, id: string, body: Record<string, unknown>) { |
| 212 | + const route = rest.getRoutes().find((r: any) => r.method === 'PATCH' && r.path === DATA_ITEM); |
| 213 | + if (!route) throw new Error('PATCH data route not registered'); |
| 214 | + const res = makeRes(); |
| 215 | + await route.handler({ method: 'PATCH', params: { object, id }, query: {}, headers: {}, body }, res); |
| 216 | + return res; |
| 217 | +} |
| 218 | + |
| 219 | +let errorSpy: ReturnType<typeof vi.spyOn>; |
| 220 | +beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); |
| 221 | +afterEach(() => { errorSpy.mockRestore(); }); |
| 222 | + |
| 223 | +describe('[#9967] the reported request on the real data route', () => { |
| 224 | + it('PATCH refused by a status-declaring body → 403 with the business message', async () => { |
| 225 | + const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal({ status: 403 })) }); |
| 226 | + |
| 227 | + const res = await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' }); |
| 228 | + |
| 229 | + expect(res.statusCode).toBe(403); |
| 230 | + expect(res.body.error).toBe('close-period lock'); |
| 231 | + expect(res.body.code).toBeUndefined(); |
| 232 | + }, 60_000); |
| 233 | + |
| 234 | + it('the declared refusal is not logged as an unhandled fault — 403 is an expected outcome', async () => { |
| 235 | + const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal({ status: 403 })) }); |
| 236 | + |
| 237 | + await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' }); |
| 238 | + |
| 239 | + const logged = errorSpy.mock.calls.some( |
| 240 | + (call: unknown[]) => JSON.stringify(call.map(String)).includes('Unhandled error'), |
| 241 | + ); |
| 242 | + expect(logged).toBe(false); |
| 243 | + }, 60_000); |
| 244 | + |
| 245 | + it('an UNDECLARED body refusal on the wire is still the verbatim 400', async () => { |
| 246 | + const rest = setup({ updateData: vi.fn().mockRejectedValue(sandboxRefusal()) }); |
| 247 | + |
| 248 | + const res = await callPatch(rest, 'showcase_task', 'rec1', { name: 'edited' }); |
| 249 | + |
| 250 | + expect(res.statusCode).toBe(400); |
| 251 | + expect(res.body.error).toBe('close-period lock'); |
| 252 | + }, 60_000); |
| 253 | +}); |
0 commit comments