diff --git a/.changeset/27501-import-job-unknown-object.md b/.changeset/27501-import-job-unknown-object.md new file mode 100644 index 00000000000..c2f0bc76370 --- /dev/null +++ b/.changeset/27501-import-job-unknown-object.md @@ -0,0 +1,20 @@ +--- +'@objectstack/rest': patch +--- + +fix(rest)!: an async import job for an unregistered object is refused with 404 `OBJECT_NOT_FOUND` before any job is created (#27501) + +Clause-②: no (narrowing) — `POST /data/:object/import/jobs` now refuses an object name the runtime does not register, where it used to answer `201` and persist a job. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at one REST data door (`@objectstack/rest`). + +- **Before.** `POST /data/no_such_object/import/jobs` answered `201` with a job id and persisted a `sys_import_job` row. The job then ran, every row failed with `OBJECT_NOT_FOUND`, and the job read `succeeded`. +- **Now.** The door answers `404` with `error.code: 'OBJECT_NOT_FOUND'`, the same envelope `POST /data/no_such_object` answers, before the body is parsed. No job row is written and no worker runs. +- **Unchanged.** A registered object gets `201` and a job exactly as before. A job whose rows partly fail still ends `succeeded`, as before. +- **Who sees it.** An import client that sends an object name nothing is behind, for example a typo. Such a request used to look accepted while it could import nothing. +- **Also refused up front.** An object of a disabled package now gets the data door's `404 OBJECT_PACKAGE_DISABLED` instead of a job (re-enable the package). A legacy `ns__short` object addressed by its short name alone gets `404 OBJECT_NOT_FOUND`, because the door counts an object only under its own name, as the API-exposure gate does (send the full registered name). + +**The remedy.** Send the object's registered name, the one `POST /data/:object` accepts. diff --git a/packages/rest/src/data-action-routes.ts b/packages/rest/src/data-action-routes.ts index 0fc081b4221..e436474a91b 100644 --- a/packages/rest/src/data-action-routes.ts +++ b/packages/rest/src/data-action-routes.ts @@ -80,7 +80,9 @@ import { sendError as sendEnvelopeError, sendOk } from '@objectstack/types'; // [#26594] The read path's ONE no-row answer, for a row that left the caller's // sight between the record read and the projection. import { recordNotFoundError } from '@objectstack/core'; -import { logError } from './log.js'; +// [#27501] The data door's answer for a name no object is behind. +import { objectNotFoundError } from '@objectstack/core'; +import { logError, logWarn } from './log.js'; import { DATA_EXPORT_PARAMS, IMPORT_JOB_OBJECT, @@ -308,6 +310,42 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro return { id, action, code: answer.code, error: answer.message }; } +/** + * [#27501] Refuse an import job for an object nothing is behind, with the data + * door's own answer for that name: `objectNotFoundError` (`@objectstack/core`), + * the one factory the protocol's existence gate and the engine's verbs throw, + * classified by the same `handleRouteError` — 404 `OBJECT_NOT_FOUND`, the same + * envelope `POST /data/OBJ` answers. + * + * The object is read by name as the exposure gate reads it (the execution face + * where the protocol has one, else `getMetaItem`), and counts only under its + * own name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its + * by-name read's answer for an object of a disabled package, and the data + * door's code for it too) propagates as thrown. Any other thrown read fails + * OPEN and logs, as the exposure gate's same read does (`loadObjectItem`, + * #3545): it is no answer about existence, and each row's data call still + * gives the data door's own. A protocol with no by-name read cannot answer, + * and is not asked. + */ +async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise { + const byName = typeof p.getMetaItemForExecution === 'function' + ? p.getMetaItemForExecution + : typeof p.getMetaItem === 'function' ? p.getMetaItem : undefined; + if (!byName) return; + let envelope: any; + try { + envelope = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) }); + } catch (err) { + if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') throw err; + logWarn('[REST] import job: object metadata read failed — not refused up front ' + + '(each row\'s data call still answers for the object)', (err as Error)?.message ?? err); + return; + } + const item = envelope?.item; + if (item !== null && typeof item === 'object' && item.name === objectName) return; + throw objectNotFoundError(objectName); +} + /** * Register object-specific action endpoints that don't fit the generic CRUD * shape — `RestServer#registerDataActionEndpoints`' body, called by that method @@ -592,6 +630,14 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath: } if (await this.enforceApiAccess(req, res, p, environmentId, 'import')) return; + // [#27501] The object must exist before a job does. The exposure + // gate above passes an unknown object through by design (#3770), + // and the data door's existence refusal only fires inside the + // per-row writes — AFTER the 201 and the persisted job, so the + // job read `succeeded` with every row refused. Refused here, up + // front, before the body is parsed and before any job row exists. + await assertImportObjectExists(p, objectName, environmentId); + // [#24348] The synchronous door's control opt-out gate, asked at // the same point: before the body is parsed and before a job row // exists, so a refused request creates no job and writes no row. diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index cfa9745e7e7..bec74b227eb 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -127,6 +127,7 @@ async function boot(decorateDriver?: (driver: any) => void) { list: find('GET', '/api/v1/data/import/jobs'), cancel: find('POST', '/api/v1/data/import/jobs/:jobId/cancel'), undo: find('POST', '/api/v1/data/import/jobs/:jobId/undo'), + dataCreate: find('POST', '/api/v1/data/:object'), }; } @@ -460,3 +461,50 @@ describe('async import job — real engine + protocol integration', () => { for (const w of restoreWrites) expect(w.context?.preserveAudit).toBeUndefined(); }); }); + +/** + * The async create door resolves the object BEFORE it persists a job: an + * object name the runtime does not register is refused up front with the + * answer the data door gives the same name (`POST /data/OBJ`, 404 + * `OBJECT_NOT_FOUND`), and no `sys_import_job` row is written — rather than a + * 201, a job id and a job whose every row then fails on that same refusal. + */ +describe('async import job — an unregistered object is refused before a job exists', () => { + let ctx: Awaited>; + beforeEach(async () => { + ctx = await boot(); + expect(ctx.create && ctx.dataCreate).toBeTruthy(); + }); + + const jobRows = async () => ctx.engine.find('sys_import_job', { where: {} }); + const call = (route: any, object: string, body: any) => { + const res = makeRes(); + return route.handler({ params: { object }, body } as any, res).then(() => res); + }; + + it('answers 404 OBJECT_NOT_FOUND, the data door\'s answer, and persists no job row', async () => { + expect(await jobRows()).toHaveLength(0); + + const res = await call(ctx.create, 'no_such_object', { format: 'csv', csv: 'title\nx\n' }); + expect(res._status).toBe(404); + expect(res._json?.error?.code ?? res._json?.code).toBe('OBJECT_NOT_FOUND'); + + // The same envelope the data door answers for the same name, byte for byte. + const door = await call(ctx.dataCreate, 'no_such_object', { title: 'x' }); + expect(door._status).toBe(404); + expect(res._json).toEqual(door._json); + + // Counted at the engine: the refusal wrote no job. + expect(await jobRows()).toHaveLength(0); + }); + + it('control: a registered object still gets 201 and exactly one job row', async () => { + const res = await call(ctx.create, 'task', { format: 'json', rows: [{ id: 'k1', title: 'kept' }] }); + expect(res._status).toBe(201); + expect(res._json).toMatchObject({ object: 'task', status: 'pending', total: 1 }); + await waitForTerminal(ctx.progress, res._json.jobId); + const rows = await jobRows(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id: res._json.jobId, object_name: 'task' }); + }); +});