Skip to content

Commit 40eab63

Browse files
fix(rest)!: GET /meta/_drafts refuses an unknown type or package filter instead of answering an empty list (#29180) (#29295)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9GbNZzHvo45yCpaxgdU2SJY)_ Fixes #29180 Clause-②: no (narrowing) — measured: on base `5ac557e5^` (`104cd1a5`) `_drafts?type=dashbord|dashboardes|viewes|nosuchtype` and `?packageId=com.qa.nosuch` answer `200 {"drafts":[]}` (the refusal pins below go red there); on head they answer `400 INVALID_REQUEST`, on both transports. Nothing is widened. Metadata-protocol: none — measured: no spec key, value or type moves; `packages/spec` is untouched. Skills-docs: none — no `skills/**` page or docs page documents the `_drafts` filters (the two docs mentions, `content/docs/ui/apps.mdx` L455/L461, cite only its capability gate). ## 维护者速读 - 改了什么:`GET /meta/_drafts` 的 `type` 写错(如 `dashbord`)或 `packageId` 指向不存在的包时,不再回 200 空列表,而是回 400,文案与列表门、诊断门对同一错误的拒绝一致。 - 为什么改:作者或 AI 在发布、回滚、丢弃前问"有没有待发布草稿",拼错就得到一个自信的"没有",随后按"无待办"行事。 - 风险与回滚:这是收窄(原先 200 的请求现在 400);正确的类型(单复数皆可)和已安装但无草稿的包仍回 200 空列表。回滚即 revert 本 PR。 - 先看:`packages/metadata-protocol/src/protocol-drafts.ts`(包检查)、`packages/rest/src/meta-collection-routes.ts`(类型检查)、`packages/runtime/src/domains/meta.ts`(派发器孪生门)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/meta-drafts-unknown-filter.test.ts`,预期 9 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **Type** (`packages/rest/src/meta-collection-routes.ts`, `packages/runtime/src/domains/meta.ts`): both `_drafts` doors ask `refuseUnknownMetaListType`, the list door's own verdict (static spelling contract, then the live type set, fail-open), before `listDrafts`. No second copy of the type vocabulary: it is the same function `GET /meta/:type` and `GET /meta/diagnostics?type=` call, so the drafts answer is pinned EQUAL to the list door's answer for the same name. - **Package** (`packages/metadata-protocol/src/protocol-drafts.ts`): `listDrafts` asks the diagnostics sweep's rule (`packageIsInstalled`, now exported from `protocol-meta-diagnostics.ts`; nothing re-exports that module, so the published surface does not move): an empty answer for a `packageId` that no pending draft (of any type) is bound to and that the installed-package listing does not carry is refused `400 INVALID_REQUEST`, same code and sentence family as the sweep's `is not an installed package`. Fails open when the listing cannot be read. Living in `listDrafts`, it covers both transports. - `DraftsHost` gains `getMetaItems` (the class hands it over as the other moved bodies do); the method docblock names the refusal. - No new error code, no new gate, no `packages/spec` edit. ## Tests New files only; no existing test was edited (no forced test edits). - `packages/metadata-protocol/src/protocol-drafts-unknown-package.test.ts` (5): refusal (status + code + first clause naming the package, with and without `type`); controls: installed package with nothing pending (no type, `dashboard`, `view`) → `{drafts: []}`; uninstalled package with a bound draft is served and, under another type filter, answers empty; listing unreadable or shapeless → fail-open empty; no package named → listing never asked. - `packages/rest/src/meta-drafts-unknown-filter.test.ts` (9): four misspellings → 400 equal to `GET /meta/:type`'s body for the same name, `listDrafts` never called; controls `dashboard`/`dashboards`/`view` → 200 empty with the arguments `listDrafts` received; known package → 200 empty; the package refusal leaves the route a 400 `INVALID_REQUEST`. - `packages/runtime/src/domains/meta-drafts-unknown-type.test.ts` (7): the dispatcher twin, same pins against its own list branch. ## Acceptance notes - `?package=` (the card's wording in one place) is not a `_drafts` parameter; the route reads `packageId` and the console sends `packageId`. Unknown parameter names on `_drafts` are not refused today (no closed set declared); out of scope here. - Console consumer reading (`ui/packages/app-shell`, 20 `listDrafts` call sites): every call that sends `type` pairs it with `client.list(type)` on `GET /meta/:type`, which already refuses the same names with the same verdict, so the new check refuses nothing the console's paired read does not already refuse. The literal types sent (`view`, `hook`, `object`, `app`, `permission`) all pass the static contract. `packageId` comes from the Studio package switcher, which lists `/api/v1/packages` (installed packages); most call sites also `.catch` the drafts read. ## Verification <details><summary>Ablation + premise (base code, head tests)</summary> The five `src` files restored to base with `git restore --source=BASE` (tree only, `grep -c 29180` = 0 on each), the three new test files run, then `git checkout HEAD --` restore; `git diff HEAD` empty, `git status --porcelain` empty, every path's `git hash-object` equal to its `HEAD` blob. - metadata-protocol: `× refuses an unknown package with nothing bound to it` — `AssertionError: type=undefined: expected undefined to be an instance of Error`; 1 failed | 4 passed (5). - rest: `× ?type=dashbord|dashboardes|viewes|nosuchtype …` — `AssertionError: expected 200 to be 400`; 4 failed | 5 passed (9). - runtime: same four — `expected 200 to be 400`; 4 failed | 3 passed (7). Every refusal pin red on base, every control green. Tests import their package's `src`; no dist resolution in these paths. </details> <details><summary>Suites, typecheck, lint (head 5ac557e)</summary> - `pnpm --filter @objectstack/{metadata-protocol,rest,runtime} build` + `typecheck`: VERDICT command-exit 0. - `@objectstack/metadata-protocol` test: 301 passed | 4 skipped files; 22105 passed | 26 skipped tests. - `@objectstack/rest` test: 368 files passed; 6972 passed | 332 skipped tests. - `@objectstack/runtime` test: 515 files passed; 8325 passed | 19 skipped tests. - ui consumers (`cd ui && vitest run` packageSurfaces, ObjectHooksPanel.firstSavePinned-11773, InterfacesPillar.customize-28562, ObjectListViewInspector.chartBinding-27933): 4 files, 41 tests passed. - eslint `--no-inline-config` over `packages/metadata-protocol packages/rest packages/runtime/src`: exit 0. - Dogfood (closure build + `pnpm --filter @objectstack/dogfood test`, no shard): VERDICT command-exit 0; Test Files 388 passed | 3 skipped (391); Tests 3208 passed | 28 skipped. </details> <details><summary>Derived gates (head 5ac557e)</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 62 families; all 62 run, each exit 0 (`check-plugin-teardown-shape --self-test` first exited 3 on the shallow clone, green after fetching its pinned fixture; `check:dual-build-cjs-loads` first exited 3 before the closure build, green after; `check:type-check-debt` run under the lock: 0 entries above record). `--ran` reconciliation: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmRSZi4SoegkVztRKBe --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRSZi4SoegkVztRKBe)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2f7977c commit 40eab63

9 files changed

Lines changed: 325 additions & 4 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(rest)!: `GET /meta/_drafts` refuses a `type` that names no metadata type and a `packageId` that names no package, the way the list and diagnostics doors refuse them, instead of answering an empty draft list (#29180)
8+
9+
Clause-②: no (narrowing) — `GET /api/v1/meta/_drafts?type=TYPO` and `?packageId=UNKNOWN`, answered `200 {"drafts":[]}` before, are refused `400 INVALID_REQUEST` on both transports (`RestServer` and the runtime dispatcher). Nothing is widened.
10+
Metadata-protocol: none
11+
12+
<!-- 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: a read filter that matched nothing is now refused with the list door's and the diagnostics sweep's existing INVALID_REQUEST refusals. A caller has nothing to rewrite except the misspelled filter value the refusal names. -->
13+
14+
**BREAKING** accept-set narrowing at the pending-drafts read (`@objectstack/rest`, `@objectstack/runtime`, `@objectstack/metadata-protocol`).
15+
16+
- **Before.** `?type=dashbord`, `?type=viewes` or `?packageId=com.qa.nosuch` narrowed the list to nothing and answered `200 {"drafts":[]}`, a confident "nothing pending" that a caller about to publish, revert or discard could not tell from a real empty list.
17+
- **Now.** A `type` is judged by the list door's own verdict (`GET /meta/dashbord` answers the same `400` and sentence): the static spelling contract, then the live type set. A `packageId` is judged by the diagnostics sweep's rule: a draft bound to the package is proof enough, otherwise the installed-package listing must carry it. Both fail open when the authority cannot be read.
18+
- **Unchanged.** A known type in either spelling (`dashboard`, `dashboards`) and an installed package with nothing pending still answer `200 {"drafts":[]}`, with or without the other filter.
19+
20+
**The remedy.** Correct the misspelled type or package id; `GET /api/v1/meta/types` lists the types and `GET /api/v1/packages` the installed packages.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29180] `listDrafts` refuses a package filter that names no package.
5+
*
6+
* `GET /meta/_drafts?packageId=com.qa.nosuch` answered `200 {"drafts":[]}`, a
7+
* confident "nothing pending" for a package that does not exist, while the
8+
* diagnostics sweep refuses the same package `400 INVALID_REQUEST` (#27514).
9+
* The draft list now asks the sweep's own question ({@link packageIsInstalled},
10+
* exported from `protocol-meta-diagnostics.ts`, not copied): a draft bound to
11+
* the package is proof enough, and only then the installed-package listing.
12+
*
13+
* Both halves are pinned: the refusal (envelope `status` + `code`, and the
14+
* sentence's first clause, which names the package) beside the controls that
15+
* must keep answering `200` — an installed package with nothing pending, with
16+
* or without a `type` filter; an uninstalled package that drafts are bound to;
17+
* a listing that cannot be read (fail-open); and no package filter at all.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { listDrafts } from './protocol-drafts.js';
22+
23+
const INSTALLED = 'com.acme.installed';
24+
const UNKNOWN = 'com.qa.nosuch';
25+
/** Not installed, but a draft is bound to it — a real package being authored. */
26+
const AUTHORING = 'com.acme.authoring';
27+
28+
const ROWS = [
29+
{ type: 'dashboard', name: 'sales', label: null, packageId: AUTHORING, updatedAt: null, updatedBy: null },
30+
];
31+
32+
function host(opts: { listing?: () => Promise<unknown> } = {}) {
33+
const repo = {
34+
listDrafts: vi.fn(async (q: { type?: string; packageId?: string }) => ROWS.filter((r) =>
35+
(q.type === undefined || r.type === q.type) && (q.packageId === undefined || r.packageId === q.packageId))),
36+
};
37+
const getMetaItems = vi.fn(opts.listing ?? (async () => ({ items: [{ manifest: { id: INSTALLED } }] })));
38+
const h = {
39+
engine: undefined,
40+
ensureOverlayIndex: vi.fn(async () => {}),
41+
getOverlayRepo: () => repo,
42+
getMetaItems,
43+
} as any;
44+
return { h, repo, getMetaItems };
45+
}
46+
47+
describe('[#29180] listDrafts — a package filter that names no package', () => {
48+
it('refuses an unknown package with nothing bound to it: 400 INVALID_REQUEST naming it', async () => {
49+
for (const type of [undefined, 'dashboard']) {
50+
const { h } = host();
51+
const err: any = await listDrafts.call(h, { packageId: UNKNOWN, ...(type ? { type } : {}) })
52+
.then(() => undefined, (e: unknown) => e);
53+
expect(err, `type=${type}`).toBeInstanceOf(Error);
54+
expect(err.status).toBe(400);
55+
expect(err.code).toBe('INVALID_REQUEST');
56+
expect(String(err.message)).toMatch(new RegExp(`^'${UNKNOWN.replace(/\./g, '\\.')}' is not an installed package,`));
57+
}
58+
});
59+
60+
it('keeps an installed package with nothing pending at 200 empty, with and without a type', async () => {
61+
for (const type of [undefined, 'dashboard', 'view']) {
62+
const { h } = host();
63+
expect(await listDrafts.call(h, { packageId: INSTALLED, ...(type ? { type } : {}) })).toEqual({ drafts: [] });
64+
}
65+
});
66+
67+
it('serves an uninstalled package that drafts are bound to, and judges it on every draft under a type filter', async () => {
68+
const { h, getMetaItems } = host();
69+
expect((await listDrafts.call(h, { packageId: AUTHORING })).drafts.map((d) => d.name)).toEqual(['sales']);
70+
// No `view` draft, but the package is bound by its dashboard draft: empty, not refused.
71+
expect(await listDrafts.call(h, { packageId: AUTHORING, type: 'view' })).toEqual({ drafts: [] });
72+
expect(getMetaItems).not.toHaveBeenCalled();
73+
});
74+
75+
it('fails OPEN when the installed-package listing cannot be read', async () => {
76+
const throwing = host({ listing: async () => { throw new Error('store down'); } });
77+
expect(await listDrafts.call(throwing.h, { packageId: UNKNOWN })).toEqual({ drafts: [] });
78+
const shapeless = host({ listing: async () => 'nothing readable' });
79+
expect(await listDrafts.call(shapeless.h, { packageId: UNKNOWN })).toEqual({ drafts: [] });
80+
});
81+
82+
it('asks nothing of the package listing when no package is named', async () => {
83+
const { h, getMetaItems } = host();
84+
expect(await listDrafts.call(h, { type: 'view' })).toEqual({ drafts: [] });
85+
expect((await listDrafts.call(h)).drafts).toHaveLength(1);
86+
expect(getMetaItems).not.toHaveBeenCalled();
87+
});
88+
});

‎packages/metadata-protocol/src/protocol-drafts.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
import type { ObjectStackProtocolImplementation } from './protocol.js';
4040
import { bundleMemberDiscriminator, isItemOfDisabledPackage } from './protocol.js';
41+
import { packageIsInstalled } from './protocol-meta-diagnostics.js';
4142
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
4243
import type { I18nLabel } from '@objectstack/spec/ui';
4344

@@ -53,12 +54,13 @@ export interface DraftsHost {
5354
readonly engine: ObjectStackProtocolImplementation['engine'];
5455
ensureOverlayIndex: ObjectStackProtocolImplementation['ensureOverlayIndex'];
5556
getOverlayRepo: ObjectStackProtocolImplementation['getOverlayRepo'];
57+
getMetaItems: ObjectStackProtocolImplementation['getMetaItems'];
5658
deleteMetaItem: ObjectStackProtocolImplementation['deleteMetaItem'];
5759
}
5860

5961
/** The body of `ObjectStackProtocolImplementation#listDrafts`; the method's docblock there is its contract. */
6062
export async function listDrafts(
61-
this: Pick<DraftsHost, 'engine' | 'ensureOverlayIndex' | 'getOverlayRepo'>,
63+
this: Pick<DraftsHost, 'engine' | 'ensureOverlayIndex' | 'getOverlayRepo' | 'getMetaItems'>,
6264
request?: {
6365
packageId?: string;
6466
type?: string;
@@ -83,6 +85,31 @@ export async function listDrafts(
8385
...(request?.type ? { type: PLURAL_TO_SINGULAR[request.type] ?? request.type } : {}),
8486
...(request?.packageId ? { packageId: request.packageId } : {}),
8587
});
88+
// [#29180] A package filter that names no package scoped the list to
89+
// nothing, and `{ drafts: [] }` read as "nothing pending in this package":
90+
// a typo answered a confident none. Refused with the diagnostics sweep's
91+
// own rule ([#27514], {@link packageIsInstalled}) — a draft bound to the
92+
// package is proof enough (rows of an uninstalled package are real), and
93+
// only then is the installed-package listing asked; an installed package
94+
// with nothing pending keeps its honest empty list, and an unreadable
95+
// listing fails OPEN. A `type` filter does not decide it: the package is
96+
// judged on every draft bound to it, so a known package with no draft of
97+
// THAT type still answers empty.
98+
if (request?.packageId && drafts.length === 0) {
99+
const bound = request.type
100+
? (await repo.listDrafts({ packageId: request.packageId })).length > 0
101+
: false;
102+
if (!bound && !(await packageIsInstalled(this, request.packageId))) {
103+
const err = new Error(
104+
`'${request.packageId}' is not an installed package, and no pending draft is bound to it, so a `
105+
+ `draft list scoped to it would report nothing pending in a package that does not exist. `
106+
+ `GET /api/v1/packages lists the installed packages; omit packageId to list every package's drafts.`,
107+
);
108+
(err as any).code = 'INVALID_REQUEST';
109+
(err as any).status = 400;
110+
throw err;
111+
}
112+
}
86113
// [#23073] The declared header (`ListDraftsResponseSchema`), with the
87114
// row's stored member on every draft of a bundled type: absent there
88115
// means the canonical member, which is spelled out here.

‎packages/metadata-protocol/src/protocol-meta-diagnostics.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,11 @@ export async function getMetaDiagnostics(
283283
/**
284284
* [#27514] Does the installed-package listing carry this id? `true` ("cannot
285285
* disprove, so do not refuse") when the listing cannot be read at all.
286+
* [#29180] Exported for the `_drafts` listing (`protocol-drafts.ts`), which
287+
* asks the same question of its own package filter; nothing re-exports this
288+
* module, so the published surface does not move.
286289
*/
287-
async function packageIsInstalled(host: Pick<MetaDiagnosticsHost, 'getMetaItems'>, packageId: string): Promise<boolean> {
290+
export async function packageIsInstalled(host: Pick<MetaDiagnosticsHost, 'getMetaItems'>, packageId: string): Promise<boolean> {
288291
let listed: unknown;
289292
try {
290293
listed = await host.getMetaItems({ type: 'package' });

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24941,6 +24941,13 @@ export class ObjectStackProtocolImplementation implements
2494124941
* the `_drafts` doors only. The package batch doors (publish, discard, the
2494224942
* batch closure) read the repository's `listDrafts` directly, so a
2494324943
* disabled package's drafts still publish and discard.
24944+
*
24945+
* [#29180] A `packageId` naming a package that is not installed and that
24946+
* no pending draft is bound to is refused `400 INVALID_REQUEST`, the
24947+
* diagnostics sweep's rule for its own package filter, rather than
24948+
* answered an empty list a caller reads as "nothing pending". An unknown
24949+
* `type` is refused by the transports before this is called, with the
24950+
* list door's verdict.
2494424951
*/
2494524952
async listDrafts(request?: {
2494624953
packageId?: string;
@@ -24964,6 +24971,7 @@ export class ObjectStackProtocolImplementation implements
2496424971
get engine() { return readEngine(); },
2496524972
ensureOverlayIndex: (...a) => this.ensureOverlayIndex(...a),
2496624973
getOverlayRepo: (...a) => this.getOverlayRepo(...a),
24974+
getMetaItems: (...a) => this.getMetaItems(...a),
2496724975
};
2496824976
return listDrafts.call(host, request);
2496924977
}

‎packages/rest/src/meta-collection-routes.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,14 +465,23 @@ export function registerMetaCollectionRoutes(this: MetaCollectionRouteHost, scop
465465
// [#23214] The list lives beside the shared decision, so the
466466
// runtime dispatcher's twin refuses the same names.
467467
if (refuseRepeatedQueryParams(req, res, metaReadGate.META_QUERY_ARITY_DOORS.drafts)) return;
468+
// [#29180] A `type` that names no metadata type narrowed the
469+
// list to nothing and answered `200 {"drafts":[]}`, a
470+
// confident "nothing pending". Refused by the list door's own
471+
// verdict ({@link refuseUnknownMetaListType}: the static
472+
// spelling contract, then the live type set), so the two
473+
// doors cannot disagree about which names are types. The
474+
// package half is `listDrafts`' own (the diagnostics rule).
475+
const draftsType = (req.query?.type as string | undefined) || undefined;
476+
if (draftsType !== undefined) await this.refuseUnknownMetaListType(p, draftsType);
468477
// [ADR-0131 D6] No organization: a draft is saved
469478
// environment-wide, so the list reads the environment
470479
// partition, symmetric with the save door. A legacy
471480
// organization-scoped draft is not listed; its
472481
// promotion is ADR-0131 C7's.
473482
const result = await (p as any).listDrafts({
474483
packageId: (req.query?.packageId as string | undefined) || undefined,
475-
type: (req.query?.type as string | undefined) || undefined,
484+
type: draftsType,
476485
});
477486
res.json(result);
478487
} catch (error: any) {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29180] `GET /meta/_drafts` refuses a `type` that names no metadata type.
5+
*
6+
* `?type=dashbord` (or `dashboardes`, `viewes`, `nosuchtype`) answered
7+
* `200 {"drafts":[]}`, a confident "nothing pending", where `GET /meta/dashbord`
8+
* refuses the same name `400`. The route now asks the list door's own verdict
9+
* (`refuseUnknownMetaListType`), so this file pins the drafts answer EQUAL to
10+
* the list door's answer for the same name on the same server — one code, one
11+
* sentence — and that `listDrafts` was never called. The controls: a known type
12+
* in either spelling still reaches `listDrafts` and answers `200` empty.
13+
*
14+
* The package half lives in `listDrafts` itself
15+
* (`@objectstack/metadata-protocol`'s `protocol-drafts-unknown-package.test.ts`);
16+
* the last case here pins that its classified refusal leaves this route as a
17+
* 400 with its code.
18+
*/
19+
20+
import { describe, it, expect, vi } from 'vitest';
21+
import { RestServer } from './rest-server.js';
22+
23+
function mockRes() {
24+
const res: any = {
25+
statusCode: 200,
26+
json: vi.fn(function (this: any) { return this; }),
27+
status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }),
28+
header: vi.fn(function (this: any) { return this; }),
29+
setHeader: vi.fn(function (this: any) { return this; }),
30+
send: vi.fn(function (this: any) { return this; }),
31+
end: vi.fn(function (this: any) { return this; }),
32+
};
33+
return res;
34+
}
35+
36+
function boot(listDrafts = vi.fn(async (_r: unknown) => ({ drafts: [] as unknown[] }))) {
37+
const protocol: any = {
38+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
39+
getMetaTypes: vi.fn().mockResolvedValue({ types: ['view', 'dashboard', 'object'] }),
40+
getMetaItems: vi.fn().mockResolvedValue({ items: [] }),
41+
listDrafts,
42+
};
43+
const server = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn() };
44+
const rest = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any);
45+
// `isSystem` clears the authoring gate that runs before the query is read.
46+
(rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' });
47+
rest.registerRoutes();
48+
const route = (path: string) => (rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path);
49+
const drive = async (path: string, params: Record<string, string>, query: Record<string, unknown>) => {
50+
const res = mockRes();
51+
await route(path).handler({ method: 'GET', path, params, query, headers: {}, body: {} }, res);
52+
return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] };
53+
};
54+
return { protocol, drive };
55+
}
56+
57+
describe('[#29180] GET /meta/_drafts — a type filter that names no type', () => {
58+
it.each(['dashbord', 'dashboardes', 'viewes', 'nosuchtype'])(
59+
'`?type=%s` answers the list door\'s own 400, and listDrafts is never called',
60+
async (type) => {
61+
const { protocol, drive } = boot();
62+
const drafts = await drive('/api/v1/meta/_drafts', {}, { type });
63+
const list = await drive('/api/v1/meta/:type', { type }, {});
64+
expect(drafts.status).toBe(400);
65+
expect(drafts.body).toEqual(list.body);
66+
expect(JSON.stringify(drafts.body)).toContain('INVALID_REQUEST');
67+
expect(JSON.stringify(drafts.body)).toContain(`'${type}' is not a metadata type.`);
68+
expect(protocol.listDrafts).not.toHaveBeenCalled();
69+
},
70+
);
71+
72+
it.each(['dashboard', 'dashboards', 'view'])(
73+
'a known type (`?type=%s`) still reaches listDrafts as sent and answers 200 empty',
74+
async (type) => {
75+
const { protocol, drive } = boot();
76+
const answer = await drive('/api/v1/meta/_drafts', {}, { type });
77+
expect(answer).toEqual({ status: 200, body: { drafts: [] } });
78+
expect(protocol.listDrafts).toHaveBeenCalledWith({ packageId: undefined, type });
79+
},
80+
);
81+
82+
it('a known package with no drafts and no type answers 200 empty', async () => {
83+
const { protocol, drive } = boot();
84+
expect(await drive('/api/v1/meta/_drafts', {}, { packageId: 'com.acme' })).toEqual({ status: 200, body: { drafts: [] } });
85+
expect(protocol.listDrafts).toHaveBeenCalledWith({ packageId: 'com.acme', type: undefined });
86+
});
87+
88+
it('the package refusal listDrafts throws leaves the route a 400 INVALID_REQUEST', async () => {
89+
const refusal = Object.assign(new Error("'com.qa.nosuch' is not an installed package, and no pending draft is bound to it."), {
90+
code: 'INVALID_REQUEST', status: 400,
91+
});
92+
const { drive } = boot(vi.fn(async () => { throw refusal; }));
93+
const answer = await drive('/api/v1/meta/_drafts', {}, { packageId: 'com.qa.nosuch' });
94+
expect(answer.status).toBe(400);
95+
expect(JSON.stringify(answer.body)).toContain('INVALID_REQUEST');
96+
expect(JSON.stringify(answer.body)).toContain('is not an installed package');
97+
});
98+
});

0 commit comments

Comments
 (0)