Skip to content

Commit 75bbebb

Browse files
fix(rest)!: the approvals request list refuses a status value no approval request can have (#30491)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgMwxPY5sbyTBJiFwuYyp3nB)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF Fixes #29880 Clause-②: yes (narrowing) — an unknown `status` value (and an empty `status=` or empty comma entry) answered 200 and now answers 400 naming the allowed values; the widening half: a comma list of declared statuses now filters on all of them, where it matched no row Metadata-protocol: none ## 维护者速读 - 改了什么:审批收件箱列表接口 `GET /api/v1/approvals/requests` 现在会核对 `status` 的取值,必须是审批请求真实存在的状态(pending、approved、rejected、recalled、returned、cancelled)。 - 为什么改:以前把状态拼错(如 `Pending`、`waiting`)会得到「没有待审」的空列表,对接的机器人或提醒脚本会静默跳过本该提醒的审批;现在答 400,并列出允许的值。 - 顺带修好:SDK 按数组查多个状态时发的是逗号列表(`pending,returned`),以前被当成一个值,永远查不到;现在按多个状态一起查。 - 风险:这是 BREAKING(收紧)。发错状态值或发空 `status=` 的脚本会开始收到 400;控制台和 SDK 只发合法值,不受影响。回滚就是 revert。 - 先看:`packages/rest/src/approvals-routes.ts` 里的 `readApprovalStatusFilter` 和列表路由调用它的那几行。 - 需要你做的:合同评审通过后,落地前需要你点头(BREAKING)。 ## What changes - **The list route reads `?status=` against the declared value set.** `readApprovalStatusFilter` (module-private, `approvals-routes.ts`) splits the value on commas, trims each entry, and requires each to be one of `APPROVAL_STATUSES` (`@objectstack/spec/contracts`), spelt exactly. Otherwise the route answers `400` with the nested `{ error: { code: 'VALIDATION_ERROR', message } }` body the name gate (#7527) and the arity gate (#6877) on the same handler answer; the message names each bad value once and lists every allowed value, and the service is not asked. - **Where the allowed set comes from.** `sys_approval_request.status`'s select options are built from `APPROVAL_STATUSES` (`sys-approval-request.object.ts`, #3786), so the route reads the object's own declaration at its source rather than keeping a second list, and needs no metadata read per inbox poll. - **Order of the gates.** Name, then arity, then value: a request that commits several errors still gets the more fundamental answer first. - **One status** reaches the service as the string it always did. **Several** reach it as the `ApprovalStatus[]` that `IApprovalService.listRequests` already accepts (pushed down as `$in`), deduplicated; `countRequests` gets the same filter, so `total` matches. - **Empty is refused.** `?status=` and an empty entry (`?status=pending,`) name no status. Today `?status=` answers every status (the service treats `''` as no filter), so reading it as absent would widen silently; it is part of the narrowing. ## Why Card measured at `589049b0`, re-measured at `c5800818` before any edit (HTTP layer, the route's own handler with a recording service): | request | before | after | |---|---|---| | `?status=pending` | 200, service got `"pending"` | unchanged | | `?status=bogus` / `?status=Pending` | 200 `{"data":[]}`, service got the string as is | 400 `VALIDATION_ERROR`, service not called | | `?status=pending,returned` | 200 `{"data":[]}`, service got `"pending,returned"` | 200, service got `["pending","returned"]` | | `?status=pending&status=returned` | 400 (arity gate) | unchanged | The comma row matters because `@objectstack/client`'s `approvals.listRequests({ status: [...] })` joins an array with commas. Validating the whole string would have turned that SDK call from a silent empty list into a 400; splitting makes it work. The seat agreed to this reading before the gates ran. ## Tests - **New `rest-server-approvals-status-value.test.ts`** (16 cases, the #7527 harness: a real `RestServer`, the registered handler, a recording approvals service). - Refusals assert status 400, nested `error.code`, the located message (each bad value, every allowed value) and that neither `listRequests` nor `countRequests` ran: `bogus`, `Pending`, a comma list with one bad entry (named once), `''`, `pending,`, a nested query object; and that the name and arity gates still answer first. - Preservation asserts the argument the service received: each of the six declared statuses as a string, a known status with no rows still `200 {"data":[]}`, a comma list as an array, trimming and dedupe, no `status` leaves the filter unset, and a paged comma-list request counts `total` on the same array. - **Updated** `rest-server-approvals-unknown-filter.test.ts`: the "every name in the closed set is accepted" case sent `'1'` to every name; it now sends `status=pending`, since `'1'` is not a status. ## Verification All at head `976da77f` (`origin/main` merged once, before the gates). - **Ablation.** `node scripts/ablation-replace.mjs` replaced `const statusRead = readApprovalStatusFilter(q.status);` with a pass-through (`{ status: q.status }`), blob `a9c203f5` → `6b2eac2a`, then ran the new file: 8 failed / 8 passed. The 8 failures are every refusal case plus the three comma-list preservation cases; the 8 that pass are the single-status and name/arity preservation cases, which must hold either way. Restored, `git diff HEAD` empty. - **Suites.** `@objectstack/rest` vitest: 392 files, 7,639 passed, 352 skipped. `pnpm --filter @objectstack/rest run typecheck` (tsc + `check:test-typecheck`): exit 0. - **Lint.** `pnpm lint`: exit 0. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 58 commands; all 58 exit 0 after a full `turbo run build --filter='!@objectstack/docs'`. `--ran` reconciles 58 derived, 58 run, 0 NOT-MEASURED, 0 UNRUN. ## Acceptance notes - No `ui/**` change is needed: the console's inbox sends only `pending` and its status picker offers `pending`, `approved`, `rejected`, `recalled`, `returned`; the bell feed sends `pending`. - Possible follow-up for the seat: the status picker in `ApprovalsInboxPage.tsx` has no `cancelled` entry although the route and the object both have it (ui lane, not touched here). ## 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. (the designated thread branch, as the Claim records) - [x] 5. A change that publishes carries a changeset (`patch`, `@objectstack/rest`), and both declaration lines are filled in here and in the changeset, with its ADR-0087 marker. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (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 (the maintainer's quick-read section is in Chinese by the seat's order). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CbAccy76jpUtQ9ZZX9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e5fabe commit 75bbebb

4 files changed

Lines changed: 294 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest)!: the approvals request list refuses a status value no approval request can have, and reads a comma list of statuses as a list
6+
7+
Clause-②: yes (narrowing) — `GET /api/v1/approvals/requests` (and its environment-scoped twin) refuses a `status` value outside the set `sys_approval_request.status` declares (`pending`, `approved`, `rejected`, `recalled`, `returned`, `cancelled`) with `400 VALIDATION_ERROR`, where it answered `200 {"data":[]}`. An empty value or an empty comma entry is refused too. The widening half: a comma list of declared statuses now filters on all of them, where it matched no row.
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: the route keeps its parameter names and the approval service contract keeps its shape. What moves is the answer to a query-string value, and nothing at rest records that answer, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the approvals request list (`@objectstack/rest`): a `status` value that is not a declared approval status is refused instead of answering an empty list.
13+
14+
- **Before.** `?status=bogus`, `?status=Pending` or `?status=waiting` answered `200 {"data":[]}`, which reads the same as "no request is in that status", so an integration polling the inbox with a misspelt status skipped every pending approval in silence. `?status=pending,returned`, the spelling `@objectstack/client`'s `approvals.listRequests({ status: [...] })` sends for an array, was read as one status and answered the same empty list. `?status=` filtered on nothing and answered every status.
15+
- **Now.** Each comma-separated entry must be a declared status, spelt exactly. Otherwise the request is refused with `400` and the nested `{ "error": { "code": "VALIDATION_ERROR", "message" } }` body the route already answers for an unknown or repeated parameter name; the message names the bad value and the allowed values, and the approval service is not asked. One declared status filters as before. Several filter on any of them (the service's `$in` push-down), and `total` counts on the same filter. A declared status with no rows still answers `200 {"data":[]}`.
16+
- **Who moves.** The console and the client SDK send declared statuses only. A script that sends another spelling, or an empty `status=`, gets the 400.
17+
18+
**The remedy.** Send one of `pending`, `approved`, `rejected`, `recalled`, `returned`, `cancelled` (lower case), or several separated by commas. To list every status, leave `status` out.

‎packages/rest/src/approvals-routes.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
// `@objectstack/types` because the producer is a PLUGIN and rest cannot
4949
// import one.
5050
import { strandedDecisionDetails, sendError as sendEnvelopeError, sendOk } from '@objectstack/types';
51+
// [#29880] The declared value set of `sys_approval_request.status`. The
52+
// object's select options are built FROM this list (#3786), so it is the
53+
// object's own declaration, read where the object reads it, and not a second
54+
// list kept beside it.
55+
import { APPROVAL_STATUSES, type ApprovalStatus } from '@objectstack/spec/contracts';
5156
import {
5257
classifiedRefusalAnswer,
5358
nestedRefusalEnvelope,
@@ -74,6 +79,52 @@ import {
7479
*/
7580
export const APPROVAL_RECORD_LOCK_PARAMS: readonly string[] = [];
7681

82+
/**
83+
* [#29880] Read `?status=` on `GET {basePath}/approvals/requests` against the
84+
* value set `sys_approval_request.status` declares.
85+
*
86+
* **Measured** at c5800818: the route checked parameter NAMES (#7527) and
87+
* handed the VALUE to the service as is, so `?status=bogus` (or `Pending`)
88+
* answered 200 `{"data":[]}`. A caller could not tell "nothing in that
89+
* state" from "that state does not exist", and an integration polling the
90+
* inbox with a misspelt status skipped every pending approval in silence.
91+
* The same reading forwarded a comma list (`?status=pending,returned`, the
92+
* spelling `@objectstack/client`'s `approvals.listRequests` sends for an
93+
* array) as ONE string, which matched no row either.
94+
*
95+
* Now each comma-separated entry must be one of {@link APPROVAL_STATUSES},
96+
* spelt exactly. One entry goes to the service as a string, several as the
97+
* array `IApprovalService.listRequests` already accepts (it pushes the array
98+
* down as `$in`). An empty value or an empty entry (`?status=`,
99+
* `?status=pending,`) names no status, so it is refused too: read as absent it
100+
* would widen the answer to every status. A known value with no rows still
101+
* answers 200 `[]`.
102+
*
103+
* Returns the filter value, `undefined` when the parameter is absent, or the
104+
* located refusal sentence.
105+
*/
106+
function readApprovalStatusFilter(
107+
raw: unknown,
108+
): { status: ApprovalStatus | ApprovalStatus[] | undefined } | { refusal: string } {
109+
if (raw === undefined) return { status: undefined };
110+
const allowed: readonly string[] = APPROVAL_STATUSES;
111+
const entries = typeof raw === 'string' ? raw.split(',').map((s) => s.trim()) : [];
112+
const unknown = typeof raw === 'string'
113+
? entries.filter((s) => !allowed.includes(s))
114+
: [String(raw)];
115+
if (unknown.length > 0) {
116+
const named = [...new Set(unknown)].map((s) => `"${s}"`).join(', ');
117+
return {
118+
refusal: `The "status" query parameter does not accept ${named}: an approval request `
119+
+ `has no such status. Allowed values: ${allowed.join(', ')}. Supply one, or several `
120+
+ 'separated by commas. This endpoint will not answer an empty list for a status '
121+
+ 'that does not exist, because that answer reads the same as "nothing in that status".',
122+
};
123+
}
124+
const statuses = [...new Set(entries)] as ApprovalStatus[];
125+
return { status: statuses.length === 1 ? statuses[0] : statuses };
126+
}
127+
77128
/**
78129
* The `RestServer` members {@link registerApprovalsEndpoints} reads — every one
79130
* of them, and no other. Each is typed AS the class member (an indexed access
@@ -307,10 +358,19 @@ export function registerApprovalsEndpoints(this: ApprovalsRouteHost, basePath: s
307358
handleRouteError(res, refusal);
308359
return;
309360
}
361+
// [#29880] The status VALUE, read against the declared set —
362+
// after the name and arity gates above, so a request committing
363+
// several errors still gets the more fundamental answer first.
364+
// Same nested envelope as both of them.
365+
const statusRead = readApprovalStatusFilter(q.status);
366+
if ('refusal' in statusRead) {
367+
res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: statusRead.refusal } });
368+
return;
369+
}
310370
const listFilter = {
311371
object: q.object,
312372
recordId: q.recordId ?? q.record_id,
313-
status: q.status,
373+
status: statusRead.status,
314374
approverId: approverIds.length ? approverIds : undefined,
315375
submitterId: q.submitterId ?? q.submitter_id,
316376
q: typeof q.q === 'string' ? q.q : undefined,
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29880] `GET /api/v1/approvals/requests` reads `?status=` against the value
5+
* set `sys_approval_request.status` declares, instead of handing any string to
6+
* the service.
7+
*
8+
* ## The measured defect (c5800818)
9+
*
10+
* `?status=bogus` and `?status=Pending` answered **200 `{"data":[]}`**: the
11+
* service filtered on a status no row can have and found nothing. A caller
12+
* could not tell "nothing in that state" from "that state does not exist".
13+
* `?status=pending,returned` (what `@objectstack/client` sends for an array)
14+
* was forwarded as ONE string and answered the same empty list.
15+
*
16+
* ## What each case asserts
17+
*
18+
* A refusal asserts the nested ADR-0112 pair the name and arity gates on this
19+
* handler answer, the located message (the value and every allowed value), and
20+
* that **the service was never asked**. A preservation case asserts the
21+
* ARGUMENT the service received, because "still 200" is exactly what the
22+
* defect looked like.
23+
*/
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
// `.js` on purpose — NodeNext resolution requires the extension, and this
27+
// package's TEST_DEBT ceiling has no margin for another TS2835 (#7248).
28+
import { RestServer } from './rest-server.js';
29+
import { APPROVAL_STATUSES } from '@objectstack/spec/contracts';
30+
31+
const APPROVALS = '/api/v1/approvals/requests';
32+
33+
function mockServer() {
34+
return {
35+
get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(),
36+
use: vi.fn(),
37+
listen: vi.fn().mockResolvedValue(undefined),
38+
close: vi.fn().mockResolvedValue(undefined),
39+
};
40+
}
41+
42+
function mockRes() {
43+
const res: any = {
44+
statusCode: 200,
45+
json: vi.fn(function (this: any, body: any) { this._body = body; return this; }),
46+
send: vi.fn(function (this: any) { return this; }),
47+
setHeader: vi.fn(function (this: any) { return this; }),
48+
status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }),
49+
};
50+
return res;
51+
}
52+
53+
/** Two rows, so "the unfiltered list came back" is visibly distinguishable. */
54+
const ALL_ROWS = [{ id: 'req_1' }, { id: 'req_2' }];
55+
56+
/**
57+
* A RestServer with a real approvals service wired, so a request that is NOT
58+
* refused runs all the way to `listRequests` — which is what lets the
59+
* preservation cases assert the filter argument rather than the status.
60+
*/
61+
function boot() {
62+
const approvals = {
63+
listRequests: vi.fn().mockResolvedValue(ALL_ROWS),
64+
countRequests: vi.fn().mockResolvedValue(2),
65+
};
66+
const protocol: any = {
67+
getDiscovery: vi.fn().mockResolvedValue({
68+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
69+
}),
70+
getMetaTypes: vi.fn().mockResolvedValue([]),
71+
getMetaItems: vi.fn().mockResolvedValue({ items: [] }),
72+
findData: vi.fn().mockResolvedValue({ records: [] }),
73+
};
74+
const rest = new RestServer(
75+
mockServer() as any,
76+
protocol as any,
77+
{ api: { requireAuth: false } } as any,
78+
undefined, // kernelManager
79+
undefined, // envRegistry
80+
undefined, // defaultEnvironmentIdProvider
81+
undefined, // authServiceProvider
82+
undefined, // objectQLProvider
83+
undefined, // emailServiceProvider
84+
undefined, // sharingServiceProvider
85+
undefined, // reportsServiceProvider
86+
async () => approvals, // approvalsServiceProvider
87+
);
88+
// `isSystem` clears the capability gates that run BEFORE the query is read,
89+
// so every request below reaches the recognition rule it is named after.
90+
(rest as any).resolveExecCtx = async () => ({ isSystem: true, userId: 'u1' });
91+
rest.registerRoutes();
92+
93+
const drive = async (query: Record<string, unknown> = {}) => {
94+
const found = (rest as any).getRoutes().find(
95+
(r: any) => r.method === 'GET' && r.path === APPROVALS,
96+
);
97+
if (!found) throw new Error(`route not registered: GET ${APPROVALS}`);
98+
const res = mockRes();
99+
await found.handler(
100+
{ method: 'GET', path: APPROVALS, params: {}, query, headers: {}, body: {} } as any,
101+
res,
102+
);
103+
return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] };
104+
};
105+
106+
return { approvals, drive };
107+
}
108+
109+
110+
/** The refusal: the shared envelope, the located sentence, no service call. */
111+
async function expectStatusRefusal(status: unknown, ...named: string[]) {
112+
const { drive, approvals } = boot();
113+
const answer = await drive({ status });
114+
expect(
115+
answer.status,
116+
`?status=${JSON.stringify(status)} must be refused, got ${answer.status} `
117+
+ `with body ${JSON.stringify(answer.body)}`,
118+
).toBe(400);
119+
expect(answer.body?.error?.code).toBe('VALIDATION_ERROR');
120+
expect(answer.body?.data).toBeUndefined();
121+
const message = String(answer.body?.error?.message);
122+
expect(message).toContain('"status"');
123+
for (const value of named) expect(message).toContain(`"${value}"`);
124+
expect(message).toContain(`Allowed values: ${APPROVAL_STATUSES.join(', ')}.`);
125+
expect(
126+
approvals.listRequests,
127+
'the service must not be asked: an empty answer for a status that does not exist is the defect',
128+
).not.toHaveBeenCalled();
129+
expect(approvals.countRequests).not.toHaveBeenCalled();
130+
}
131+
132+
/** The status argument the service received for one request. */
133+
async function statusHandedToService(query: Record<string, unknown>) {
134+
const { drive, approvals } = boot();
135+
const answer = await drive(query);
136+
expect(answer.status, `body ${JSON.stringify(answer.body)}`).toBe(200);
137+
expect(approvals.listRequests).toHaveBeenCalledTimes(1);
138+
return { filter: approvals.listRequests.mock.calls[0][0], answer, approvals };
139+
}
140+
141+
describe('#29880 §1 — a status value outside the declared set is refused', () => {
142+
it('?status=bogus answers 400 naming the value and the allowed values', async () => {
143+
await expectStatusRefusal('bogus', 'bogus');
144+
});
145+
146+
it('the match is exact: ?status=Pending is refused, not folded to pending', async () => {
147+
await expectStatusRefusal('Pending', 'Pending');
148+
});
149+
150+
it('one bad entry in a comma list refuses the whole request, and each bad entry is named once', async () => {
151+
await expectStatusRefusal('pending,waiting,waiting', 'waiting');
152+
const { drive } = boot();
153+
const { body } = await drive({ status: 'pending,waiting,waiting' });
154+
expect(String(body?.error?.message).match(/"waiting"/g)).toHaveLength(1);
155+
});
156+
157+
it('an empty value or an empty entry names no status and is refused, not read as "every status"', async () => {
158+
await expectStatusRefusal('', '');
159+
await expectStatusRefusal('pending,', '');
160+
});
161+
162+
it('a value that is not a string (a nested query object) is refused', async () => {
163+
const { drive, approvals } = boot();
164+
const answer = await drive({ status: { $ne: 'pending' } });
165+
expect(answer.status).toBe(400);
166+
expect(answer.body?.error?.code).toBe('VALIDATION_ERROR');
167+
expect(approvals.listRequests).not.toHaveBeenCalled();
168+
});
169+
170+
it('the name and arity gates still answer first', async () => {
171+
const { drive } = boot();
172+
const unknownName = await drive({ status: 'bogus', assignedToMe: 'true' });
173+
expect(String(unknownName.body?.error?.message)).toContain('"assignedToMe"');
174+
const repeated = await drive({ status: ['bogus', 'pending'] });
175+
expect(String(repeated.body?.error?.message)).toContain('supplied 2 times');
176+
});
177+
});
178+
179+
describe('#29880 §2 — every declared status, and lists of them, still reach the service', () => {
180+
it.each([...APPROVAL_STATUSES])('?status=%s is handed to the service as that string', async (value) => {
181+
const { filter } = await statusHandedToService({ status: value });
182+
expect(filter.status).toBe(value);
183+
});
184+
185+
it('a known status with no rows still answers 200 with an empty list', async () => {
186+
const { drive, approvals } = boot();
187+
approvals.listRequests.mockResolvedValue([]);
188+
const answer = await drive({ status: 'cancelled' });
189+
expect(answer.status).toBe(200);
190+
expect(answer.body).toEqual({ data: [] });
191+
expect(approvals.listRequests.mock.calls[0][0].status).toBe('cancelled');
192+
});
193+
194+
it('a comma list reaches the service as an array (the spelling @objectstack/client sends)', async () => {
195+
const { filter } = await statusHandedToService({ status: 'pending,returned' });
196+
expect(filter.status).toEqual(['pending', 'returned']);
197+
});
198+
199+
it('spaces around entries are trimmed and a repeated entry is sent once', async () => {
200+
const { filter } = await statusHandedToService({ status: ' approved , rejected,approved' });
201+
expect(filter.status).toEqual(['approved', 'rejected']);
202+
});
203+
204+
it('no ?status= leaves the filter unset, and paging still counts on the same filter', async () => {
205+
const { filter } = await statusHandedToService({});
206+
expect(filter.status).toBeUndefined();
207+
const paged = await statusHandedToService({ status: 'pending,returned', limit: '10' });
208+
expect(paged.approvals.countRequests).toHaveBeenCalledTimes(1);
209+
expect(paged.approvals.countRequests.mock.calls[0][0].status).toEqual(['pending', 'returned']);
210+
expect(paged.answer.body).toEqual({ data: [{ id: 'req_1' }, { id: 'req_2' }], total: 2 });
211+
});
212+
});

‎packages/rest/src/rest-server-approvals-unknown-filter.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,11 @@ describe('#7527 §2 — every parameter the route really reads still works', ()
253253
it('every name in the closed set is accepted — none of them is refused', async () => {
254254
// Asserts against the exported array rather than a hand-copied list, so
255255
// the pin cannot drift away from what the route actually declares.
256+
// `status` is sent a declared status: since #29880 its VALUE is read
257+
// against `sys_approval_request.status`, and `'1'` is not one.
256258
for (const name of APPROVAL_REQUEST_LIST_PARAMS) {
257259
const { drive } = boot();
258-
const answer = await drive({ [name]: '1' });
260+
const answer = await drive({ [name]: name === 'status' ? 'pending' : '1' });
259261
expect(answer.status, `"${name}" is declared supported but was refused`).toBe(200);
260262
}
261263
});

0 commit comments

Comments
 (0)