Skip to content

Commit fdf844c

Browse files
fix(rest)!: both import doors answer for the object as the data door does (#27728, #27729) (#27897)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DRif1zFPxSGzmPepjdDRQNb)_ Fixes #27728 Fixes #27729 domain:server#3 · os-elon-musk · session_016WV4cxENxbA8ZpRgwafjvs Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no hand-written docs page or published skill states what either import door answers for an unregistered object, a disabled package's object, or a short name (`git grep` of `content/docs` outside `references/`/`releases/` and of `skills/` for the import routes with `OBJECT_NOT_FOUND`, `OBJECT_PACKAGE_DISABLED`, 404 or "not found": 0 hits; `wire-format.mdx` names the sync route only as the bulk door, `import-mappings.mdx` documents per-row outcomes). ## 维护者速读 - 改了什么:同步导入 `POST /data/OBJ/import` 现在先问对象在不在:没注册的对象回 404 `OBJECT_NOT_FOUND`,停用包里的对象回 404 `OBJECT_PACKAGE_DISABLED`,和数据门 `POST /data/OBJ` 一字不差(原来回 200,错误藏在每行结果里)。两个导入门与 API 暴露闸都改用同一种读法:旧式 `ns__short` 对象用短名访问时,认的就是数据门认的那个对象。 - 为什么改:按分诊 6509235875(#27728)与 6509225810(#27729):只看状态码的导入客户端,拼错对象名时会以为成功;作业门对短名比数据门更严;暴露闸对短名放行,`apiEnabled: false` 的 `ns__short` 对象用短名能被读写(实测)。 - 风险与回滚:收窄,已写 BREAKING 说明;只影响拼错/未注册/停用包的对象名和带 `__` 的旧对象名,已发运的对象都不带 `__`。回滚即删 `data-action-routes.ts` 里同步门那一行调用,再把两处 `item.name === name` 比较加回去。 - 先看:`packages/rest/src/data-action-routes.ts`(同步门新增的 `assertImportObjectExists` 调用与函数里去掉的名字比较);`packages/rest/src/meta-item-read-gate.ts` 的 `loadObjectItem`;新测试 `packages/rest/src/import-door-object-existence.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/import-door-object-existence.test.ts`,预期 11/11 通过。 - 需要你做的:无,席位落地(合同复审由席位安排)。 ## What changes - **#27728, the synchronous import door.** `POST /data/:object/import` now calls `assertImportObjectExists` right after the API-exposure gate and before the control opt-out gate and the body parse, the same place the async job door asks it (#27711). An unregistered object answers the data door's 404 `OBJECT_NOT_FOUND`, byte for byte; an object of a disabled package answers its 404 `OBJECT_PACKAGE_DISABLED`. Nothing is parsed or written, dry run included. Placed after the exposure gate, so a hidden object keeps its 404 and the door is no existence oracle. - **#27729 item 1, pinned.** The async job door's disabled-package refusal (propagated since #27711, read from the code) is now measured: status and body `toEqual` the data door's answer for the same name, zero `sys_import_job` rows, zero object rows. It was already green on the base, as #27711 said. - **#27729 item 2, one read.** `assertImportObjectExists` and the exposure gate's `loadObjectItem` (`meta-item-read-gate.ts`) both dropped `item.name === name`: whatever item the by-name read serves counts. That read (`getMetaItemForExecution`, else `getMetaItem`) resolves a legacy `ns__short` object from its short name exactly as the data door's `registry.getObject` does (measured: `widget` → `exl__widget` on both). Results: - the job door creates a job for a short name the data door serves, where it answered `OBJECT_NOT_FOUND`; - the exposure gate judges the resolved object's `enable`. Before, a short name was "no object here" to the gate and passed through, so an `ns__short` object with `enable.apiEnabled: false` was served under its short name (measured on the base: `POST /data/vault` 201, sync import 200, while `POST /data/exl__vault` answered 404 `OBJECT_API_DISABLED`). Now all three doors answer 404 `OBJECT_API_DISABLED` for the short name too. - The list fallback (a protocol with no by-name read) keeps its name match; it has no resolution to follow, and the docblock says so. - Changeset: `@objectstack/rest` patch, BREAKING note, ADR-0087 `not-required (no-migration-prescription)` marker. ## Tests `packages/rest/src/import-door-object-existence.test.ts` (new), real `RestServer` routes on a real `ObjectQL` engine + `ObjectStackProtocolImplementation`, SQLite `:memory:`, real `sys_import_job`, real `disablePackage`: - #27728: unregistered object → sync door 404 equal to the data door's body; dry run and an unparseable CSV body get the same answer; control: a registered object still imports (200, 1 row). - #27729 item 1: disabled package → data door 404 `OBJECT_PACKAGE_DISABLED`; sync and job doors equal it (status + body), zero job rows, zero object rows. - #27729 item 2: premise (`registry.getObject('widget')` → `exl__widget`, data door 201); job door 201 + one job row for the short name; sync door imports under it; an unregistered short name is still `OBJECT_NOT_FOUND` at both import doors, zero job rows; `apiEnabled: false` object hidden under its short name at the data, sync and job doors, zero rows. Ablation (both source files reverted to `main`, test kept): 6 of 11 red (the two #27728 cases, the sync disabled-package case, and three short-name cases); the 5 that stay green are the controls, the data-door reference reading, and the job-door disabled-package pin (already true on the base). Restored: 11/11. ## Acceptance notes - Triage rulings followed as written; nothing re-ruled. - Out of scope, read from the code and not measured: the cross-object `POST /batch` per-op exposure gate (`batch-routes.ts`) keys the object LIST by `name`, so it keeps the old short-name pass-through. This harness's runtime answers 501 for a transactional batch, so it could not be measured here. Noted for the seat; not filed. ## Verification Head `c0b19922`, base `main` `30f8fdd3` (#27795 and #27711 already in the base). - `pnpm --filter @objectstack/rest exec vitest run` (whole package): Test Files 353 passed (353), Tests 7031 passed, 350 skipped. - `pnpm --filter @objectstack/rest run typecheck`: exit 0. `pnpm lint`: exit 0. - dispatch-gates: 60 derived families, 59 run with exit 0, 1 NOT MEASURED: `pnpm check:dual-build-cjs-loads` exits 3 (needs every package's dist; CI builds it). `check-plugin-teardown-shape --self-test` first exited 3 in the shallow clone and passed (0) after fetching its pinned commit. `--ran` reconciliation: "60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED". <details><summary>Dogfood battery (narrowing arm), full, under the verify lock, at c0b1992</summary> `pnpm --filter @objectstack/dogfood test` after the `@objectstack/dogfood^...` closure build. `os-verify-lock: VERDICT command-exit 1`. Test Files 3 failed, 353 passed, 3 skipped (359); Tests 4 failed, 2867 passed, 20 skipped (2891). None is this diff's (none reads an import door, the exposure gate or a `__` name): - `error-sentences-live.dogfood.test.ts`, acceptance[1], both `POST /data/OBJECT` and `/createMany`; and `object-hook-lifecycle-scratch.dogfood.test.ts`, acceptance[6] negative[0]: the `HOOK_ABORTED` main red tracked as #27806 (fix in PR #27878). - `platform-app-object-entry-views.test.ts`, "every object an entry names is declared by the composition or by the identity barrel": `expected [ 'sys_exchange_rate' ] to deeply equal []`. A main-side red from `194e66c0` (`sys_exchange_rate` named by a Setup entry the dogfood composition does not register), tracked as the second red of #27813. Named, not fixed. </details> The dogfood shards do not run on the PR or in the merge queue for this diff (their `if:` excludes both events), so the reading above is the only one before the merge. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (Two cards carried together on the seat's order: one file, one read.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cxENxbA8ZpRgwafjvs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e41642d commit fdf844c

4 files changed

Lines changed: 276 additions & 21 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: both import doors answer for the object as the data door does — the synchronous import refuses an unregistered object 404, and a legacy short name is the object behind it at every door (#27728, #27729)
6+
7+
Clause-②: no (narrowing) — `POST /data/:object/import` no longer answers 200 for an unregistered object or an object of a disabled package; it answers the data door's 404. And on every `/data/:object` route, a legacy `ns__short` object addressed by its short name is now held to its own `enable` settings (404 or 405). No value, key or shape is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: two already-published refusals (404 OBJECT_NOT_FOUND, 404 OBJECT_PACKAGE_DISABLED) are now answered up front by one more door, and the exposure gate judges the object a short name resolves to. There is nothing for a migration to rewrite and no tombstone. -->
11+
12+
**BREAKING** the synchronous import door refuses what it used to accept with a 200, and the API-exposure gate of every `/data/:object` route now judges a legacy `ns__short` object addressed by its short name (`@objectstack/rest`), per triage 6509235875 (#27728) and 6509225810 (#27729).
13+
14+
- **Before.** `POST /data/:object/import` ran no existence check. For a name with no object behind it, it answered 200 and repeated `OBJECT_NOT_FOUND` inside every row's result; for an object of a disabled package it did the same with `OBJECT_PACKAGE_DISABLED`. An importer that reads the status saw success.
15+
- **Now.** It answers the data door's own refusal for the name, status and body: 404 `OBJECT_NOT_FOUND`, or 404 `OBJECT_PACKAGE_DISABLED`. It is asked after the API-exposure gate (so a hidden object keeps its 404 and the door is no existence oracle) and before the body is parsed, so nothing is written, dry run included. The async job door (`POST /data/:object/import/jobs`) already refused both; its disabled-package refusal is now pinned with zero job rows.
16+
- **One read for existence and exposure.** A legacy `ns__short` object addressed by its short name is the object behind that name at every door, as `registry.getObject` resolves it for the data door. The async job door used to refuse such a name `OBJECT_NOT_FOUND` while the data door served it; it now creates the job. The API-exposure gate used to treat such a name as "no object" and pass it on, so an `ns__short` object with `enable.apiEnabled: false` or an `enable.apiMethods` list was served under its short name; the gate now judges that object's `enable` and refuses the same way it refuses the full name. No shipped object carries a `__` name.
17+
- **Who sees it.** A client that imports into a misspelled or unregistered object name, or into an object of a disabled package, now gets a 404 instead of a 200 with per-row errors. A client that addresses a legacy `ns__short` object by its short name on any `/data/:object` route, where that object sets `enable.apiEnabled: false` or an `enable.apiMethods` list that leaves the operation out, now gets that object's 404 `OBJECT_API_DISABLED` or 405 `OBJECT_API_METHOD_NOT_ALLOWED`, which it already got under the full name.
18+
19+
**The remedy.** Import into a registered object name (or re-enable the package). A client that already read the per-row `OBJECT_NOT_FOUND` should read the response status and `code` instead. For a restricted `ns__short` object, the short name now answers as the full name does: lift the object's `enable` restriction if the API should serve it.

‎packages/rest/src/data-action-routes.ts‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -319,21 +319,26 @@ function importUndoFailure(id: string, action: ImportUndoFailure['action'], erro
319319
}
320320

321321
/**
322-
* [#27501] Refuse an import job for an object nothing is behind, with the data
323-
* door's own answer for that name: `objectNotFoundError` (`@objectstack/core`),
324-
* the one factory the protocol's existence gate and the engine's verbs throw,
325-
* classified by the same `handleRouteError` — 404 `OBJECT_NOT_FOUND`, the same
326-
* envelope `POST /data/OBJ` answers.
322+
* [#27501, #27728] Refuse an import for an object nothing is behind, with the
323+
* data door's own answer for that name: `objectNotFoundError`
324+
* (`@objectstack/core`), the one factory the protocol's existence gate and the
325+
* engine's verbs throw, classified by the same `handleRouteError` — 404
326+
* `OBJECT_NOT_FOUND`, the same envelope `POST /data/OBJ` answers. Both import
327+
* doors ask it, the synchronous one (`POST /data/OBJ/import`) and the job door
328+
* (`POST /data/OBJ/import/jobs`), right after the exposure gate and before the
329+
* body is parsed, so a refused request writes no row and no job.
327330
*
328331
* The object is read by name as the exposure gate reads it (the execution face
329-
* where the protocol has one, else `getMetaItem`), and counts only under its
330-
* own name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its
331-
* by-name read's answer for an object of a disabled package, and the data
332-
* door's code for it too) propagates as thrown. Any other thrown read fails
333-
* OPEN and logs, as the exposure gate's same read does (`loadObjectItem`,
334-
* #3545): it is no answer about existence, and each row's data call still
335-
* gives the data door's own. A protocol with no by-name read cannot answer,
336-
* and is not asked.
332+
* where the protocol has one, else `getMetaItem`). [#27729] Whatever item that
333+
* read serves counts, under whichever name it carries: the by-name read
334+
* resolves a legacy `ns__short` object from its short name exactly as the data
335+
* door's `registry.getObject` does, so this answer is the data door's for every
336+
* name. A read the protocol refuses `OBJECT_PACKAGE_DISABLED` (its by-name
337+
* read's answer for an object of a disabled package, and the data door's code
338+
* for it too) propagates as thrown. Any other thrown read fails OPEN and logs,
339+
* as the exposure gate's same read does (`loadObjectItem`, #3545): it is no
340+
* answer about existence, and each row's data call still gives the data door's
341+
* own. A protocol with no by-name read cannot answer, and is not asked.
337342
*/
338343
async function assertImportObjectExists(p: any, objectName: string, environmentId: string | undefined): Promise<void> {
339344
const byName = typeof p.getMetaItemForExecution === 'function'
@@ -345,12 +350,12 @@ async function assertImportObjectExists(p: any, objectName: string, environmentI
345350
envelope = await byName.call(p, { type: 'object', name: objectName, ...(environmentId ? { environmentId } : {}) });
346351
} catch (err) {
347352
if ((err as { code?: unknown } | null)?.code === 'OBJECT_PACKAGE_DISABLED') throw err;
348-
logWarn('[REST] import job: object metadata read failed — not refused up front '
353+
logWarn('[REST] import: object metadata read failed — not refused up front '
349354
+ '(each row\'s data call still answers for the object)', (err as Error)?.message ?? err);
350355
return;
351356
}
352357
const item = envelope?.item;
353-
if (item !== null && typeof item === 'object' && item.name === objectName) return;
358+
if (item !== null && typeof item === 'object') return;
354359
throw objectNotFoundError(objectName);
355360
}
356361

@@ -517,6 +522,14 @@ export function registerDataActionEndpoints(this: DataActionRouteHost, basePath:
517522
return;
518523
}
519524
if (await this.enforceApiAccess(req, res, p, environmentId, 'import', context)) return;
525+
526+
// [#27728] The object must exist before any row is read. The
527+
// exposure gate above passes an unknown object through by design
528+
// (#3770), and the data door's existence refusal used to fire only
529+
// inside each row's write, so a misspelled name answered 200 with
530+
// every row refused. Refused here, with the data door's answer.
531+
await assertImportObjectExists(p, objectName, environmentId);
532+
520533
const body = req.body ?? {};
521534

522535
// [#24348] Who may take the object's declared controls off this
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27728, #27729] Both import doors answer for the object exactly as the data
5+
* door does (`POST /data/:object`), before any row or job is written:
6+
*
7+
* - the synchronous door (`POST /data/:object/import`) refuses an unregistered
8+
* object with the data door's 404 `OBJECT_NOT_FOUND`, where it used to answer
9+
* 200 with the refusal repeated inside every row's result (#27728);
10+
* - both doors refuse an object of a DISABLED package with the data door's
11+
* 404 `OBJECT_PACKAGE_DISABLED`, status and body, and write no row and no
12+
* `sys_import_job` row (#27729 item 1);
13+
* - a legacy `ns__short` object addressed by its short name is the object
14+
* behind that name at every door: the existence read and the API-exposure
15+
* gate both take the item the by-name read resolves, as the data door's
16+
* `registry.getObject` does, so the job door no longer refuses a name the
17+
* data door serves, and the gate judges that object's `enable` instead of
18+
* passing the short name through (#27729 item 2).
19+
*
20+
* A REAL `ObjectQL` engine + `ObjectStackProtocolImplementation` on a REAL
21+
* sqlite `:memory:` driver; nothing below the routes is a double.
22+
*/
23+
24+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
25+
import { ObjectQL } from '@objectstack/objectql';
26+
import { SqlDriver } from '@objectstack/driver-sql';
27+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
28+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
29+
import { SysImportJob } from '@objectstack/platform-objects/audit';
30+
import { RestServer } from './rest-server';
31+
import { installDefaultOrganization } from './default-organization.testkit.js';
32+
33+
const liveEngines: ObjectQL[] = [];
34+
afterEach(async () => {
35+
while (liveEngines.length) {
36+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
37+
}
38+
});
39+
40+
const fields = {
41+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
42+
title: { name: 'title', type: 'text' as const, label: 'Title' },
43+
};
44+
45+
/** A plain object, the control. */
46+
const TASK = { name: 'ex_task', label: 'Task', systemFields: false, fields };
47+
/** An object of a package the test disables. */
48+
const OFF_PACKAGE = 'app.ex_off';
49+
const GADGET = { name: 'ex_gadget', label: 'Gadget', systemFields: false, fields };
50+
/** Legacy `ns__short` objects, addressed below by their short names. */
51+
const LEGACY_PACKAGE = 'app.ex_legacy';
52+
const WIDGET = { name: 'exl__widget', label: 'Widget', systemFields: false, fields };
53+
const VAULT = { name: 'exl__vault', label: 'Vault', systemFields: false, enable: { apiEnabled: false }, fields };
54+
55+
function makeRes() {
56+
const res: any = {
57+
write: () => true, end: () => {},
58+
header: () => res,
59+
status: (code: number) => { res._status = code; return res; },
60+
json: (body: any) => { res._json = body; return res; },
61+
};
62+
return res;
63+
}
64+
65+
async function boot() {
66+
const engine = new ObjectQL();
67+
liveEngines.push(engine);
68+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
69+
await engine.init();
70+
engine.registry.installPackage({ id: OFF_PACKAGE, name: OFF_PACKAGE, version: '1.0.0' } as any, 'code');
71+
engine.registry.installPackage({ id: LEGACY_PACKAGE, name: LEGACY_PACKAGE, version: '1.0.0' } as any, 'code');
72+
engine.registry.registerObject(TASK as any);
73+
engine.registry.registerObject(GADGET as any, OFF_PACKAGE);
74+
engine.registry.registerObject(WIDGET as any, LEGACY_PACKAGE);
75+
engine.registry.registerObject(VAULT as any, LEGACY_PACKAGE);
76+
engine.registry.registerObject(SysImportJob as any);
77+
await engine.syncSchemas();
78+
// A real `single` install's Default Organization (ADR-0131 D9).
79+
await installDefaultOrganization(engine);
80+
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
81+
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
82+
}
83+
84+
const protocol = new ObjectStackProtocolImplementation(engine as any);
85+
const noop = () => {};
86+
const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
87+
const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
88+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
89+
rest.registerRoutes();
90+
const routes = rest.getRoutes();
91+
const route = (path: string): any => {
92+
const found = routes.find((r: any) => r.method === 'POST' && r.path === path);
93+
if (!found) throw new Error(`route POST ${path} is not registered`);
94+
return found;
95+
};
96+
const doors = {
97+
data: route('/api/v1/data/:object'),
98+
sync: route('/api/v1/data/:object/import'),
99+
jobs: route('/api/v1/data/:object/import/jobs'),
100+
};
101+
return {
102+
engine,
103+
async call(door: keyof typeof doors, object: string, body: any) {
104+
const res = makeRes();
105+
await doors[door].handler({ params: { object }, body, query: {} } as any, res);
106+
// The synchronous door answers with `res.json` alone on success.
107+
return { status: res._status ?? 200, body: res._json };
108+
},
109+
/** Rows of a table, counted at the engine. */
110+
async count(table: string) {
111+
const rows = await engine.find(table, { where: {} });
112+
return Array.isArray(rows) ? rows.length : 0;
113+
},
114+
};
115+
}
116+
117+
const rowsBody = (id: string) => ({ format: 'json', rows: [{ id, title: 'x' }] });
118+
119+
describe('[#27728] the synchronous import door refuses an unregistered object as the data door does', () => {
120+
let h: Awaited<ReturnType<typeof boot>>;
121+
beforeEach(async () => { h = await boot(); });
122+
123+
it('answers 404 OBJECT_NOT_FOUND, the data door\'s envelope byte for byte, not 200', async () => {
124+
const door = await h.call('data', 'ex_no_such_object', { id: 'n1', title: 'x' });
125+
expect(door.status).toBe(404);
126+
expect(door.body?.code).toBe('OBJECT_NOT_FOUND');
127+
128+
const sync = await h.call('sync', 'ex_no_such_object', rowsBody('n2'));
129+
expect(sync.status).toBe(404);
130+
expect(sync.body).toEqual(door.body);
131+
});
132+
133+
it('a dry run and a CSV body get the same refusal, before the body is parsed', async () => {
134+
const door = await h.call('data', 'ex_no_such_object', { id: 'n1', title: 'x' });
135+
const dry = await h.call('sync', 'ex_no_such_object', { ...rowsBody('n3'), dryRun: true });
136+
expect(dry).toEqual({ status: 404, body: door.body });
137+
// Not even a parseable body is needed: the object is asked first.
138+
const csv = await h.call('sync', 'ex_no_such_object', { format: 'csv', csv: 'not,a\n"broken' });
139+
expect(csv).toEqual({ status: 404, body: door.body });
140+
});
141+
142+
it('control: a registered object still imports (200, one row created)', async () => {
143+
const sync = await h.call('sync', 'ex_task', rowsBody('t1'));
144+
expect(sync.status).toBe(200);
145+
expect(sync.body).toMatchObject({ object: 'ex_task', total: 1, ok: 1, created: 1, errors: 0 });
146+
expect(await h.count('ex_task')).toBe(1);
147+
});
148+
});
149+
150+
describe('[#27729 item 1] both import doors refuse a disabled package\'s object with the data door\'s answer', () => {
151+
let h: Awaited<ReturnType<typeof boot>>;
152+
beforeEach(async () => {
153+
h = await boot();
154+
h.engine.registry.disablePackage(OFF_PACKAGE);
155+
});
156+
157+
it('the data door\'s answer is 404 OBJECT_PACKAGE_DISABLED (the reference)', async () => {
158+
const door = await h.call('data', 'ex_gadget', { id: 'g0', title: 'x' });
159+
expect(door.status).toBe(404);
160+
expect(door.body?.code).toBe('OBJECT_PACKAGE_DISABLED');
161+
});
162+
163+
for (const kind of ['sync', 'jobs'] as const) {
164+
it(`${kind}: status and body equal the data door's, and no row and no job row is written`, async () => {
165+
const door = await h.call('data', 'ex_gadget', { id: 'g0', title: 'x' });
166+
const answer = await h.call(kind, 'ex_gadget', rowsBody('g1'));
167+
expect(answer.status).toBe(door.status);
168+
expect(answer.body).toEqual(door.body);
169+
expect(await h.count('sys_import_job')).toBe(0);
170+
expect(await h.count('ex_gadget')).toBe(0);
171+
});
172+
}
173+
});
174+
175+
describe('[#27729 item 2] a legacy ns__short object is the object behind its short name at every door', () => {
176+
let h: Awaited<ReturnType<typeof boot>>;
177+
beforeEach(async () => { h = await boot(); });
178+
179+
it('premise: the data door serves the short name (registry.getObject resolves it)', async () => {
180+
expect((h.engine.registry.getObject('widget') as any)?.name).toBe('exl__widget');
181+
const door = await h.call('data', 'widget', { id: 'w0', title: 'x' });
182+
expect(door.status).toBe(201);
183+
});
184+
185+
it('the job door creates the job, where it used to answer OBJECT_NOT_FOUND', async () => {
186+
const jobs = await h.call('jobs', 'widget', rowsBody('w1'));
187+
expect(jobs.status).toBe(201);
188+
expect(jobs.body).toMatchObject({ object: 'widget', status: 'pending', total: 1 });
189+
expect(await h.count('sys_import_job')).toBe(1);
190+
});
191+
192+
it('the synchronous door imports under the short name', async () => {
193+
const sync = await h.call('sync', 'widget', rowsBody('w2'));
194+
expect(sync.status).toBe(200);
195+
expect(sync.body).toMatchObject({ object: 'widget', ok: 1, created: 1 });
196+
});
197+
198+
it('an unregistered short name is still OBJECT_NOT_FOUND at both import doors', async () => {
199+
const door = await h.call('data', 'gizmo', { id: 'z0', title: 'x' });
200+
expect(door.status).toBe(404);
201+
expect(await h.call('sync', 'gizmo', rowsBody('z1'))).toEqual(door);
202+
expect(await h.call('jobs', 'gizmo', rowsBody('z2'))).toEqual(door);
203+
expect(await h.count('sys_import_job')).toBe(0);
204+
});
205+
206+
it('the exposure gate judges the resolved object: apiEnabled:false hides it under its short name too', async () => {
207+
const full = await h.call('data', 'exl__vault', { id: 'v0', title: 'x' });
208+
expect(full.status).toBe(404);
209+
expect(full.body?.code).toBe('OBJECT_API_DISABLED');
210+
for (const kind of ['data', 'sync', 'jobs'] as const) {
211+
const short = await h.call(kind, 'vault', kind === 'data' ? { id: 'v1', title: 'x' } : rowsBody('v1'));
212+
expect(short.status).toBe(404);
213+
expect(short.body?.code).toBe('OBJECT_API_DISABLED');
214+
}
215+
expect(await h.count('exl__vault')).toBe(0);
216+
expect(await h.count('sys_import_job')).toBe(0);
217+
});
218+
});

0 commit comments

Comments
 (0)