Skip to content

Commit 31b2550

Browse files
committed
test(core): the runner fault enumeration names the requested object, and holds its controls
Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d457a70 commit 31b2550

2 files changed

Lines changed: 56 additions & 9 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/types': patch
3+
---
4+
5+
fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are.
10+
11+
**What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence.
12+
13+
**Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`.

‎packages/core/src/utils/import-runner-sandbox-fault-row.test.ts‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,13 @@
4848
* not raise. `import-runner-sandbox-refusal-row.test.ts` §4 is the driver-text
4949
* control #22718's ablation B pinned, and it is not edited here.
5050
*
51-
* The sample object name in a rendered message is deliberately NOT the
52-
* import's object. `mapDataError`'s unknown-object heuristic answers `404
53-
* OBJECT_NOT_FOUND` for a text that names the requested object and contains
54-
* `not`, which a capability denial on that object does. That is a door
55-
* defect outside this card; the row does not copy a 4xx sentence.
51+
* [#22868] The sample object name in a rendered message IS the import's
52+
* object. Two sites name it — the `ctx.api.object(…)` capability gate and the
53+
* missing-method arm — and `mapDataError`'s unknown-object heuristic answered
54+
* those `404 OBJECT_NOT_FOUND` (a quoted requested-object name plus `not`)
55+
* until the door asked `isSandboxOwnFault` first. So §1 holds each site that
56+
* names the requested object to the door's `500`, and §3 holds the controls
57+
* that keep the heuristic's other answers where they were.
5658
*/
5759

5860
import { readFileSync } from 'node:fs';
@@ -68,10 +70,12 @@ const HERE = dirname(fileURLToPath(import.meta.url));
6870
const RUNNER = resolve(HERE, '../../../runtime/src/sandbox/quickjs-runner.ts');
6971
const SOURCE = ts.createSourceFile(RUNNER, readFileSync(RUNNER, 'utf8'), ts.ScriptTarget.Latest, true);
7072

73+
const OBJECT = 'mz_locked';
74+
7175
/**
7276
* One sample per interpolation the runner's fault messages use. A message
7377
* naming an expression not listed here is unrenderable, and §1's census says
74-
* which. `objectName` is not the import's object (see the header).
78+
* which. `objectName` is the import's object (see the header).
7579
*/
7680
const SAMPLES: Readonly<Record<string, string>> = {
7781
'args.origin.kind': 'hook',
@@ -82,7 +86,7 @@ const SAMPLES: Readonly<Record<string, string>> = {
8286
wallCeiling: '30000',
8387
pumps: '3',
8488
'formatErr(msg)': 'SyntaxError: unexpected token',
85-
objectName: 'mz_other',
89+
objectName: OBJECT,
8690
method: 'find',
8791
required: 'api.read',
8892
fieldName: 'owner',
@@ -219,6 +223,11 @@ const census = (() => {
219223

220224
const PRODUCER_NAME = producerName();
221225

226+
/** [#22868] The fault sites whose rendered message quotes the requested object. */
227+
function namingTheObject(): Site[] {
228+
return census.faults.filter((s) => s.message.includes(`'${OBJECT}'`));
229+
}
230+
222231
/** The producer's own fault: its declared `name`, and no `innerMessage`. */
223232
function ownFault(message: string): Error {
224233
const err = new Error(message);
@@ -228,8 +237,6 @@ function ownFault(message: string): Error {
228237

229238
type CreateArgs = Parameters<ImportProtocolLike['createData']>[0];
230239

231-
const OBJECT = 'mz_locked';
232-
233240
const baseOpts = {
234241
objectName: OBJECT,
235242
metaMap: new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]),
@@ -280,6 +287,11 @@ describe('[#22741] §1 — every fault the runner raises itself reads the door\'
280287
}
281288
// §2's population: the constructions that set `innerMessage` (crash and refusal sites).
282289
expect(census.crashes.length).toBeGreaterThanOrEqual(4);
290+
// [#22868] The sites that name the requested object: the `ctx.api.object(…)`
291+
// capability gate and the missing-method arm, each held to `500` below.
292+
const named = namingTheObject();
293+
expect(named.filter((s) => /^capability '/.test(s.message)).length, 'capability denial').toBeGreaterThanOrEqual(1);
294+
expect(named.filter((s) => /not implemented$/.test(s.message)).length, 'missing ctx.api method').toBeGreaterThanOrEqual(1);
283295
});
284296

285297
it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
@@ -331,6 +343,28 @@ describe('[#22741] §3 — controls: what the predicate must NOT change', () =>
331343
expect((await rowFor(err)).error).toBe(sanitizeRowError(budget()));
332344
});
333345

346+
it.each([
347+
['SQLite', `SQLITE_ERROR: no such table: ${OBJECT}`],
348+
['Postgres', `relation "${OBJECT}" does not exist`],
349+
])('CONTROL [#22868] — a real %s unknown-object text on the requested object still answers 404', (_driver, text) => {
350+
const door = mapDataError(new Error(text), OBJECT);
351+
expect(door.status).toBe(404);
352+
expect(door.body.code).toBe('OBJECT_NOT_FOUND');
353+
});
354+
355+
it('CONTROL [#22868] — the same object-naming texts, thrown by anything but the sandbox, keep the heuristic\'s 404', () => {
356+
// The door asks the producer's identity, never the words: no second rule.
357+
const named = namingTheObject();
358+
expect(named.length).toBeGreaterThanOrEqual(2);
359+
for (const { label, message } of named) {
360+
const err = new Error(message);
361+
expect(isSandboxOwnFault(err), label).toBe(false);
362+
const door = mapDataError(err, OBJECT);
363+
expect(door.status, label).toBe(404);
364+
expect(door.body.code, label).toBe('OBJECT_NOT_FOUND');
365+
}
366+
});
367+
334368
it('CONTROL — a code-defined hook\'s native TypeError keeps its text, as the filing says', async () => {
335369
const text = "Cannot read properties of undefined (reading 'owner')";
336370
const err = () => new TypeError(text);

0 commit comments

Comments
 (0)