Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/22718-import-row-sandbox-fault.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@objectstack/core': patch
---

An import row for a sandboxed hook body that crashed, or that refused with a declared 5xx status, reads as a server fault instead of relaying the fault's text

Clause-②: no

When a hook body crashed during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: TypeError: …`, and a hook refusal that declared a 5xx status relayed its own message. `POST /api/v1/data/:object` and `/createMany` answer both as server faults and withhold that text. The row now carries the same sentence those routes answer (`Internal server error`), and its `code` is unchanged: `IMPORT_ROW_FAILED` for a crash, the declared code for a declared 5xx. A hook refusal with no status or a 4xx status still reads in the hook's own words, and a database error's row text is unchanged.
39 changes: 27 additions & 12 deletions packages/core/src/utils/import-runner-sandbox-refusal-row.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@
* §3 pins door-to-row agreement directly: the row's sentence IS what
* `mapDataError` (the create door) answers for the same error, including for a
* sentence `sanitizeRowError` would have paraphrased. §4 holds the controls.
* §5 (#22718) holds the two sandboxed failures the door answers as a fault, a
* crashed body and a refusal that declared a 5xx: their row reads the door's
* sentence.
*/

import { describe, it, expect, vi } from 'vitest';
import { mapDataError } from '@objectstack/types';
import { mapDataError, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
import { runImport, sanitizeRowError, type ImportProtocolLike } from './import-runner';
import type { ExportFieldMeta } from './import-field-meta.js';

Expand Down Expand Up @@ -176,17 +179,6 @@ describe('[#22694] §3 — the row answers what the create door answers', () =>
});

describe('[#22694] §4 — controls: what the read must NOT change', () => {
it('CONTROL — a hook body that CRASHED is not a refusal: its row is built from `.message` exactly as before', async () => {
const crash = sandboxCrash();
const row = await rowFor(sandboxCrash);

// `sandboxBusinessMessage` declines a crash, so the native error text is
// never presented as the author's sentence.
expect(row.error).not.toBe('TypeError: boom');
expect(row.error).toBe(sanitizeRowError(crash.message));
expect(row.code).toBe('IMPORT_ROW_FAILED');
});

it('CONTROL — a plain error whose own text looks like the wrapper is relayed as it always was', async () => {
// No `innerMessage`: nothing about this error came from the sandbox, so a
// pattern-strip would be the only thing that could change it.
Expand All @@ -206,5 +198,28 @@ describe('[#22694] §4 — controls: what the read must NOT change', () => {
const sql = "insert into `mz_locked` (`name`) values ('r1') - SQLITE_BUSY: database is locked";
const row = await rowFor(() => new Error(sql));
expect(row.error).toBe('SQLITE_BUSY: database is locked');
// [#22718] The door answers this as a fault too, so §5's gate is the
// sandbox origin, never the door's 5xx alone.
expect(mapDataError(new Error(sql), OBJECT).status).toBeGreaterThanOrEqual(500);
});
});

/** A refusal that DECLARED a server-band status: the doors keep the status and code and withhold the prose. */
const sandboxDeclared5xx = () => sandboxRefusal('The ledger service is down.', { status: 503, code: 'SERVICE_UNAVAILABLE' });

describe('[#22718] §5 — a sandboxed body the door answers as a fault reads as that fault', () => {
it.each([
['a body that CRASHED', sandboxCrash, 'IMPORT_ROW_FAILED', /TypeError|boom|threw:/],
['a refusal that declared a 5xx', sandboxDeclared5xx, 'SERVICE_UNAVAILABLE', /ledger|threw:/],
] as const)('%s', async (_label, err, code, leak) => {
const door = mapDataError(err(), OBJECT);
// Anti-vacuity: the door answers a fault and withholds the text.
expect(door.status).toBeGreaterThanOrEqual(500);
expect(door.body.error).toBe(INTERNAL_ERROR_MESSAGE);

const row = await rowFor(err);
// The door's sentence, never the native text or the prose; the code still reads as a fault.
expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: door.body.error, code });
expect(JSON.stringify(row)).not.toMatch(leak);
});
});
29 changes: 22 additions & 7 deletions packages/core/src/utils/import-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { DroppedFieldsEvent } from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { CreateDataRequest, FindDataRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api';
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js';
import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage } from '@objectstack/types';
import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin } from '@objectstack/types';

/**
* import-runner — the shared row-processing core for bulk import.
Expand Down Expand Up @@ -459,22 +459,37 @@ export function sanitizeRowError(raw: unknown): string {
* passed through {@link sanitizeRowError}. That function cleans DRIVER text,
* and an author's sentence fed to it is paraphrased: one that opens with an
* SQL verb ("Update the cost centre first.") reads as a leaked statement and
* comes back as the generic database sentence. A body that CRASHED is not a
* refusal: {@link sandboxBusinessMessage} declines it, so its row is built
* exactly as before, from `.message` through {@link sanitizeRowError}. A
* `code` the body declared still rides, read from the error as it always was.
* comes back as the generic database sentence. A `code` the body declared
* still rides, read from the error as it always was.
*
* ## A sandboxed body the door answers as a fault reads as that fault (#22718)
*
* Two sandboxed failures are not refusals, and the create door withholds
* their text: a body that CRASHED (`TypeError: boom`, which
* {@link sandboxBusinessMessage} declines) answers `500 INTERNAL_ERROR`, and a
* refusal that declared a 5xx status answers that status with its prose
* withheld. The row used to relay both: the crash as the wrapper through
* {@link sanitizeRowError}, the declared 5xx as the body's prose. So for a
* sandboxed error the door answers with a 5xx, the row's text is the door's
* own sentence. Which errors are faults and what their sentence is are both
* `mapDataError`'s verdict, ⛔ never a second withholding rule here. The row's
* `code` is unchanged, so it still reads as a failed row with a fault code.
* Every other error keeps its row text: driver text is still cleaned by
* {@link sanitizeRowError}, which is the import's own reading of it.
*/
function toFailedResult(rowNo: number, err: unknown, objectName: string): ImportRowResult {
const e = err as { code?: unknown; message?: unknown; fields?: unknown; field?: unknown } | null | undefined;
const first = firstFinding(e?.fields);
const door = mapDataError(err, objectName);
if (first === undefined) {
const adopted = adoptDoorVerdict(rowNo, mapDataError(err, objectName).body);
const adopted = adoptDoorVerdict(rowNo, door.body);
if (adopted !== undefined) return adopted;
}
const thrownCode = isEngineDuplicateRecordEnvelope(e) ? 'UNIQUE_VIOLATION' : e?.code;
const code = first?.code ?? thrownCode ?? 'IMPORT_ROW_FAILED';
const field = first?.field != null && first.field !== '' ? first.field : e?.field;
const message = sandboxBusinessMessage(err) ?? sanitizeRowError(e?.message);
const sandboxFault = isSandboxOrigin(err) && door.status >= 500 ? String(door.body.error) : undefined;
const message = sandboxFault ?? sandboxBusinessMessage(err) ?? sanitizeRowError(e?.message);
return {
row: rowNo, ok: false, action: 'failed', error: message, code: String(code),
...(field != null && field !== '' ? { field: String(field) } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
*/

import { describe, it, expect } from 'vitest';
import { sanitizeRowError } from '@objectstack/core';
import { RestServer } from './rest-server.js';
import { mountAndRecordDirectRoutes } from './direct-mount-composition.js';
import { REST_ROUTE_LEDGER } from './rest-route-ledger.js';
Expand Down Expand Up @@ -391,19 +390,43 @@ describe('[#22694] §3 MEASURED, NOT REPAIRED: these doors answer the debug wrap
}
});

describe('[#22694] §4 CONTROL: a hook body that CRASHED still answers as a fault on both import doors', () => {
const crash = sandboxCrash();
/** A refusal that DECLARED a server-band status: the doors keep the status and withhold the prose. */
function sandboxDeclared5xx(): Error {
const err = new Error("hook 'mz_guard' threw: Error: The ledger service is down.");
err.name = 'SandboxError';
return Object.assign(err, { innerMessage: 'The ledger service is down.', status: 503, code: 'SERVICE_UNAVAILABLE' });
}

/**
* [#22718] A body that CRASHED and a refusal that declared a 5xx are answered
* as faults by the data doors, with their text withheld. Both import rows are
* held to that answer. §2 is the control: a refusal row keeps the hook's
* sentence on both import doors.
*/
describe('[#22718] §4 a sandboxed body the doors answer as a fault reads as that fault on both import doors', () => {
it.each([
['POST /api/v1/data/:object/import', importRow, undefined],
['POST /api/v1/data/:object/import/jobs', jobRow, IMPORT_JOB_PROTOCOL],
] as const)('%s', async (route, answer, protocol) => {
const b = boot(sandboxCrash, protocol?.(sandboxCrash));
const res = await drive(b, route, IMPORT_BODY);

const row = await answer(res, b);
// The native error text is never presented as the author's sentence;
// the row is built from `.message` exactly as before this card.
expect(row).not.toBe('TypeError: boom');
expect(row).toBe(sanitizeRowError(crash.message));
['a body that CRASHED', sandboxCrash, /TypeError|boom/],
['a refusal that declared a 5xx', sandboxDeclared5xx, /ledger/],
] as const)('%s', async (_label, error, leak) => {
const doors: unknown[] = [];
for (const [route, body] of [['POST /api/v1/data/:object', { name: 'x' }], ['POST /api/v1/data/:object/createMany', [{ name: 'x' }]]] as const) {
const res = await drive(boot(error), route, body);
expect(res.statusCode, route).toBeGreaterThanOrEqual(500);
doors.push(sentenceOf(res.body));
}
expect(doors[1]).toBe(doors[0]);
expect(String(doors[0])).not.toMatch(leak);

for (const [route, answer, protocol] of [
['POST /api/v1/data/:object/import', importRow, undefined],
['POST /api/v1/data/:object/import/jobs', jobRow, IMPORT_JOB_PROTOCOL],
] as const) {
const b = boot(error, protocol?.(error));
const row = await answer(await drive(b, route, IMPORT_BODY), b);

expect(b.calls, route).toContain('protocol.insertManyData');
expect(row, route).toBe(doors[0]);
expect(String(row), route).not.toMatch(WRAPPER_RE);
}
}, 60_000);
});
Loading