diff --git a/.changeset/rest-4xx-message-truncate-not-replace.md b/.changeset/rest-4xx-message-truncate-not-replace.md new file mode 100644 index 0000000000..2110bbb652 --- /dev/null +++ b/.changeset/rest-4xx-message-truncate-not-replace.md @@ -0,0 +1,13 @@ +--- +'@objectstack/rest': patch +--- + +REST 的显式状态直通:4xx 错误消息超过 500 字符时**截断**,不再整条替换成 `Request failed` + +`mapDataError` 与 `resolveErrorResponse`(`sendError` 的取值端)两处 4xx 直通分支,过去都以 500 字符为界把整条 message 换成字面量 `Request failed` —— `status` 和 `code` 照常落地,正文一个字不剩。这把激励方向弄反了:驱动层那些拒收信息**唯一的存在意义**就是告诉作者哪个操作符/字段写错了、协议是怎么声明的,而 driver-sql 里写得最细的两条(#5158 未降解的 `FilterArray`、#5347 非布尔 `$null` 比较值)恰好都越过 500 字符,于是客户端只收到 `{ "code": "INVALID_FILTER", "error": "Request failed" }`。更反直觉的是:这两条**不带** `status` 时反而能原文直达(走 `mapDataError` 末尾的 `{ status: 400, body: { error: raw } }`),#4436 给它们加 `status: 400` 是为了赋予 ADR-0112 的 wire 身份,却在这一档让可读性变差了。 + +现在超长消息按 `message.slice(0, 499) + '…'` 截断,与驱动侧 `safeShapePreview` 同源。这些消息把主句(操作符、字段、path、收到了什么、协议怎么声明)放在最前,被截掉的是尾部的归因和 issue 号 —— 本就该留在日志里而非响应里的部分。上限仍是 500,变的是**到达上限时的处理方式**;短于 500 的消息逐字不变。 + +影响面不止过滤器:任何携带 4xx `status` 的领域错误同享此修复,包括 metadata save 校验的 422(实测一条五 issue 的 `INVALID_METADATA` 就在这条线上下)、plugin-sharing 的 record-scope 403 等。 + +`sendError` 一侧的直通区间是 400–599,其中 **5xx 的整条替换刻意保持不变**:4xx 的正文是写给调用方的补救说明,5xx 的正文是服务端故障的日志诊断 —— 这与 `mapDataError` 同族分支「deliberately limited to 4xx」的既有取向一致。 diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 1ddd569b34..11703e6fe9 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -450,10 +450,22 @@ const SQLITE_TIME_EXPR_REFS = 8; * (`malformedFilterArrayError` / `unusableFilterError`): one condition — "this * filter cannot run" — has one wire code however the caller reached it. * - * `status: 400` makes `@objectstack/rest`'s `sendError` pass the message - * through instead of routing it to the SQL-leak heuristic, and puts the - * rejection on the `isExpectedQueryRejection` list so a client mistake stops - * being logged as an unhandled server error. + * `status: 400` puts the rejection on `@objectstack/rest`'s + * `isExpectedQueryRejection` list, so a client mistake stops being logged as an + * unhandled server error. + * + * It does NOT decide whether the message text survives, and the claim that it + * "makes `sendError` pass the message through instead of routing it to the + * SQL-leak heuristic" was backwards (#5423): WITHOUT a status these messages + * already reached the client verbatim through `mapDataError`'s final + * `{ status: 400, body: { error: raw } }` — the leak heuristics do not match + * this wording. WITH the status they entered the explicit-status passthrough, + * whose 500-character bound used to swap the whole body text for + * `'Request failed'` — so in that band adding the status made the message LESS + * readable, the opposite of what this comment promised. That bound now + * truncates instead of replacing, so the main clause survives either way; the + * tail (attribution, issue numbers) may be cut. Keep the actionable part — + * operator, field, path, what arrived, what the spec declares — at the FRONT. * * The `[sql-driver]` prefix these messages used to carry is GONE from the text: * it is driver-internal wording, and shipping it to clients is exactly what the diff --git a/packages/rest/src/rest-4xx-message-truncation.test.ts b/packages/rest/src/rest-4xx-message-truncation.test.ts new file mode 100644 index 0000000000..f0fa2568f8 --- /dev/null +++ b/packages/rest/src/rest-4xx-message-truncation.test.ts @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#5423] A 4xx domain error's message is TRUNCATED at the passthrough bound, +// never swapped wholesale for 'Request failed'. +// +// Both explicit-status passthrough branches in `rest-server.ts` bounded the +// message at 500 characters by REPLACING it: `code` and `status` landed as +// usual and every word of the body text disappeared. Nothing in the suite ever +// looked at the long-message side of either branch — the one assertion that +// touched it (`rest.test.ts`, "guards the passthrough message length") pinned +// the replacement as if it were the intent — which is how the behaviour stayed +// invisible while the messages it silences grew past the bound. +// +// It inverted the incentive on the whole rejection vocabulary. driver-sql's +// filter refusals exist ONLY to tell an author which operator or field they got +// wrong and how the spec declares it; #5158's unlowered-`FilterArray` and +// #5347's non-boolean `$null` refusals are both over 500 characters, so the two +// most carefully worded rejections in the driver were the two the client could +// not read at all. Worse, they were readable BEFORE they carried `status: 400` +// (`mapDataError`'s final `{ status: 400, body: { error: raw } }` ships the raw +// text and the leak heuristics do not match this wording) — #4436 added the +// status to give them a wire identity and, in this band, cost them their body. +// +// Reverse verification, direction predicted BEFORE running: restoring the +// wholesale replacement turns every "long" case here RED (they assert on text +// that only exists once the message survives) and leaves every "short" case +// GREEN (short messages are byte-for-byte unchanged by this fix — that is what +// those cases are for: they catch the opposite overreach, a "fix" that starts +// mangling messages that were always fine). + +import { describe, it, expect, vi } from 'vitest'; +import { mapDataError, RestServer } from './rest-server'; + +/** The bound both branches use. Unchanged by #5423 — only what happens at it. */ +const MAX = 500; + +// --------------------------------------------------------------------------- +// Realistic long 4xx messages +// --------------------------------------------------------------------------- + +/** + * driver-sql's `nonBooleanNullComparandError` (#5347/#5368), instantiated the + * way a real request produces it: `{ status: { $null: "false" } }`. + * + * Copied rather than imported — `@objectstack/rest` must not take a dependency + * on a driver package to run its own tests. The wording is what matters: the + * MAIN CLAUSE (operator, field, what arrived, what the spec declares) is at the + * front, the attribution and issue number at the back. + */ +const NULL_COMPARAND_MESSAGE = + `Operator "$null" on field "status" requires a boolean comparand (true or false). ` + + `Received string ("false") at where.status.$null. ` + + `@objectstack/spec FieldOperatorsSchema declares $null as a boolean. It is refused rather ` + + `than coerced because the backends read a non-boolean in OPPOSITE directions — this driver ` + + `compiled IS NULL (anything but false), driver-memory's query path and driver-mongodb ` + + `compiled IS NOT NULL (anything but true), and driver-memory's matcher dropped the ` + + `constraint entirely. Note "false" the STRING is truthy, so it landed on the side opposite ` + + `the false it was written to mean (#5347).`; + +function invalidFilterError(message: string) { + return Object.assign(new Error(message), { code: 'INVALID_FILTER', status: 400 }); +} + +/** A 600-character 4xx whose leading sentence is identifiable after slicing. */ +function longClientError(status: number, code: string) { + const head = 'The main clause a caller must read is right here at the front. '; + return Object.assign( + new Error(head + 'x'.repeat(600 - head.length)), + { code, status }, + ); +} + +// --------------------------------------------------------------------------- +// mapDataError — the branch the generic data routes reach directly +// --------------------------------------------------------------------------- + +describe('mapDataError: 4xx passthrough truncates an over-long message (#5423)', () => { + it('a 600-character 4xx keeps its main clause instead of becoming "Request failed"', () => { + const r = mapDataError(longClientError(400, 'INVALID_FILTER'), 'showcase_account'); + + expect(r.status).toBe(400); + expect(r.body.code).toBe('INVALID_FILTER'); + expect(r.body.object).toBe('showcase_account'); + // The regression this issue is about. + expect(r.body.error).not.toBe('Request failed'); + // The part worth reading survived, verbatim and at the front. + expect(r.body.error).toContain('The main clause a caller must read is right here at the front.'); + // ...and it is still bounded. + expect(String(r.body.error)).toHaveLength(MAX); + expect(String(r.body.error).endsWith('…')).toBe(true); + }); + + it("#5347's $null refusal reaches the client with its operator/field/spec sentence intact", () => { + // The concrete case #5423 was raised on. Guard the premise first: if + // this message ever drops under the bound the assertions below stop + // proving anything, so assert it is genuinely in the truncated band. + expect(NULL_COMPARAND_MESSAGE.length).toBeGreaterThanOrEqual(MAX); + + const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE), 'showcase_account'); + + expect(r.status).toBe(400); + expect(r.body.code).toBe('INVALID_FILTER'); + expect(r.body.error).toContain('Operator "$null" on field "status" requires a boolean comparand'); + expect(r.body.error).toContain('Received string ("false") at where.status.$null'); + expect(r.body.error).toContain('FieldOperatorsSchema declares $null as a boolean'); + // What is cut is the tail — attribution and issue number, the part that + // belongs in the log rather than in the response. + expect(r.body.error).not.toContain('(#5347)'); + }); + + it('the truncated text is a PREFIX of the original — no reordering, no summarising', () => { + const r = mapDataError(invalidFilterError(NULL_COMPARAND_MESSAGE)); + const body = String(r.body.error); + + expect(body.slice(0, -1)).toBe(NULL_COMPARAND_MESSAGE.slice(0, MAX - 1)); + expect(NULL_COMPARAND_MESSAGE.startsWith(body.slice(0, -1))).toBe(true); + }); +}); + +describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', () => { + it('a normal-length message passes through with no ellipsis and no slicing', () => { + const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1'; + const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 })); + + expect(r.status).toBe(403); + expect(r.body.error).toBe(msg); + }); + + it('exactly 499 characters is still verbatim; exactly 500 is the first truncated length', () => { + const at499 = mapDataError(Object.assign(new Error('y'.repeat(499)), { status: 400 })); + expect(at499.body.error).toBe('y'.repeat(499)); + + const at500 = mapDataError(Object.assign(new Error('y'.repeat(500)), { status: 400 })); + expect(String(at500.body.error)).toHaveLength(MAX); + expect(at500.body.error).toBe(`${'y'.repeat(MAX - 1)}…`); + }); + + it('an absent or empty message still degrades to generic text — nothing to truncate', () => { + expect(mapDataError({ status: 400, code: 'X' }).body.error).toBe('Request failed'); + expect(mapDataError(Object.assign(new Error(''), { status: 400, code: 'X' })).body.error) + .toBe('Request failed'); + }); + + it('5xx never enters this branch at all (unchanged: sanitizing heuristics own it)', () => { + const r = mapDataError( + Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 '.repeat(20)), { status: 502 }), + ); + expect(r.status).not.toBe(502); + }); +}); + +// --------------------------------------------------------------------------- +// sendError — walked through a real route, in-process +// +// The issue read this branch statically and said so ("`sendError` 那处是同款 +// 写法,未单独走通"). It is walked here: a registered metadata route rejects, +// the handler's catch calls `sendError`, and the assertions read the body the +// client would actually receive. +// --------------------------------------------------------------------------- + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +function setup(protocolOverrides: Record = {}) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', endpoints: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn().mockResolvedValue({}), + saveMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + ...protocolOverrides, + }; + const rest = new RestServer( + createMockServer() as any, + protocol, + { api: { requireAuth: false } } as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +async function callRoute(rest: any, method: string, path: string, req: Record) { + const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path); + if (!route) throw new Error(`${method} ${path} route not registered`); + const res = makeRes(); + await route.handler({ method, params: {}, query: {}, body: {}, headers: {}, ...req }, res); + return res; +} + +/** + * The metadata save validator's 422 — the NON-FILTER 4xx the issue asked to be + * sampled, confirming the bound bites well outside driver-sql's filter family. + * + * Built the way `metadata-protocol`'s `saveMetaItem` builds it: the first THREE + * issues are summarised as `: ` joined by `; `, behind an + * `[invalid_metadata] / failed spec validation: ` prefix, with a + * `(+N more)` suffix for the remainder. + * + * Worth recording how close this family runs to the line: the same fixture with + * three issues and no suffix measured 492 characters — under the bound by 8. + * A metadata save is not an exotic path and a five-issue rejection is not an + * exotic mistake, so this family straddles the cliff exactly as #5423 suspected + * the near-miss filter refusals (#5240 at ~469, #5327 at ~454) do. + */ +function invalidMetadataError() { + const issues = [ + { path: 'fields.amount.type', message: 'Invalid enum value. Expected one of text | number | currency | date | datetime | boolean | select | lookup | master_detail | formula | rollup, received "money"', code: 'invalid_enum_value' }, + { path: 'fields.owner.referenceTo', message: 'Required — a lookup field must name the object it references, and the name must be a registered object', code: 'invalid_type' }, + { path: 'views.grid_default.columns', message: 'Expected array, received string — a grid view declares its columns as a list of field names', code: 'invalid_type' }, + { path: 'fields.status.options', message: 'Required — a select field must declare its options', code: 'invalid_type' }, + { path: 'label', message: 'Required', code: 'invalid_type' }, + ]; + const summary = issues.slice(0, 3).map((i) => `${i.path}: ${i.message}`).join('; '); + return Object.assign( + new Error( + `[invalid_metadata] object/maint_asset failed spec validation: ${summary}` + + (issues.length > 3 ? ` (+${issues.length - 3} more)` : ''), + ), + { code: 'INVALID_METADATA', status: 422, issues }, + ); +} + +describe('sendError: the same bound, walked through a real route (#5423)', () => { + it('a metadata-save 422 keeps its leading sentence and its structured issues', async () => { + const err = invalidMetadataError(); + // Premise guard: this must actually be in the truncated band. + expect(err.message.length).toBeGreaterThanOrEqual(MAX); + + const rest = setup({ saveMetaItem: vi.fn().mockRejectedValue(err) }); + const res = await callRoute(rest, 'PUT', '/api/v1/meta/:type/:name', { + params: { type: 'object', name: 'maint_asset' }, + body: { name: 'maint_asset', label: 'Asset' }, + }); + + expect(res.statusCode).toBe(422); + expect(res.body.code).toBe('INVALID_METADATA'); + expect(res.body.error).not.toBe('Request failed'); + expect(res.body.error).toContain('[invalid_metadata] object/maint_asset failed spec validation'); + expect(res.body.error).toContain('fields.amount.type'); + expect(String(res.body.error)).toHaveLength(MAX); + expect(String(res.body.error).endsWith('…')).toBe(true); + // The structured half of the envelope is untouched by any of this. + expect(Array.isArray(res.body.issues)).toBe(true); + expect(res.body.issues).toHaveLength(5); + }, 60_000); + + it('a 600-character 404 truncates too — this is not special-cased per status', async () => { + const rest = setup({ getMetaItem: vi.fn().mockRejectedValue(longClientError(404, 'NO_DRAFT')) }); + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { + params: { type: 'object', name: 'showcase_account' }, + }); + + expect(res.statusCode).toBe(404); + expect(res.body.code).toBe('NO_DRAFT'); + expect(res.body.error).toContain('The main clause a caller must read is right here at the front.'); + expect(String(res.body.error)).toHaveLength(MAX); + }, 60_000); + + it('a short message is byte-for-byte what it always was', async () => { + const msg = '[no_draft] No pending draft exists for object/showcase_account.'; + const rest = setup({ + getMetaItem: vi.fn().mockRejectedValue( + Object.assign(new Error(msg), { code: 'NO_DRAFT', status: 404 }), + ), + }); + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { + params: { type: 'object', name: 'showcase_account' }, + }); + + expect(res.statusCode).toBe(404); + expect(res.body).toEqual({ error: msg, code: 'NO_DRAFT' }); + }, 60_000); + + it('an over-long 5xx is DELIBERATELY still replaced — the asymmetry is the point', async () => { + // This branch's passthrough range is 400-599, wider than mapDataError's. + // A 4xx message is addressed to the caller and is the remedy; a 5xx + // message is a server fault's log diagnostic that happens to be + // reachable here, and `mapDataError`'s sibling branch is already + // "deliberately limited to 4xx ... so internal/SQL details never reach + // the client verbatim". #5423 does not widen 5xx leniency. + const rest = setup({ + getMetaItem: vi.fn().mockRejectedValue( + Object.assign(new Error('z'.repeat(600)), { code: 'INTERNAL', status: 503 }), + ), + }); + const res = await callRoute(rest, 'GET', '/api/v1/meta/:type/:name', { + params: { type: 'object', name: 'showcase_account' }, + }); + + expect(res.statusCode).toBe(503); + expect(res.body.error).toBe('Request failed'); + }, 60_000); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 910b0c1b8a..76e274f819 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -365,6 +365,49 @@ export function zodIssuesToFields( return out; } +/** + * How many characters of a domain error's OWN message reach the client. + * + * Deliberately the same 500 the two status-passthrough branches have always + * used — #5423 changed what happens AT the bound, not where the bound sits. + */ +const CLIENT_MESSAGE_MAX = 500; + +/** + * [#5423] Bound an explicit-status domain error's message by TRUNCATING it, + * never by replacing it wholesale. + * + * Both status-passthrough branches (in {@link mapDataError} and + * {@link resolveErrorResponse}) used to swap any message of 500+ characters for + * the literal `'Request failed'` — `code` and `status` landed as usual and the + * entire body text vanished. That inverted the incentive on every carefully + * worded rejection in the repo: the driver-sql filter refusals exist ONLY to + * tell an author which operator/field they got wrong and how the spec declares + * it, and the two longest of them (#5158's unlowered `FilterArray`, #5347's + * `$null` non-boolean comparand) were already over the line — so the more + * precisely a rejection was written, the more certainly the client received + * nothing but `{ "code": "INVALID_FILTER", "error": "Request failed" }`. + * Adding `status: 400` to make a message client-visible (#4436's intent) made + * it strictly LESS readable in that band. + * + * Truncation keeps the part that is worth reading. These messages front-load + * the main clause — the operator, the field, the path, what arrived and what + * the spec declares — and back-load attribution and issue numbers, which + * belong in the log rather than the response. + * + * The bound is NOT a leak defence and never was: length is not a proxy for + * "contains SQL", a 200-character driver dump passed the old gate untouched, + * and these messages have already cleared `looksLikeInternalErrorLeak` / + * `isSqlLeak` before reaching here. Same shape as the drivers' own + * `safeShapePreview` (`packages/plugins/driver-sql`), which previews rather + * than erases. + */ +function truncateClientMessage(message: string): string { + return message.length < CLIENT_MESSAGE_MAX + ? message + : `${message.slice(0, CLIENT_MESSAGE_MAX - 1)}…`; +} + export function mapDataError(error: any, object?: string): { status: number; body: Record } { // Referential-integrity restrict on delete → 409 with the dependent count. // Surfaced FIRST so the structured fields survive the generic catch-alls. @@ -564,8 +607,11 @@ export function mapDataError(error: any, object?: string): { status: number; bod // 5xx messages keep going through the sanitizing heuristics below so // internal/SQL details never reach the client verbatim. if (typeof error?.status === 'number' && error.status >= 400 && error.status < 500) { - const msg = typeof error?.message === 'string' && error.message.length > 0 && error.message.length < 500 - ? error.message + // An over-long message is TRUNCATED, not swapped for generic text + // (#5423) — see {@link truncateClientMessage}. A missing or empty one + // still degrades to `'Request failed'`: there is nothing to truncate. + const msg = typeof error?.message === 'string' && error.message.length > 0 + ? truncateClientMessage(error.message) : 'Request failed'; return { status: error.status, @@ -785,9 +831,26 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo const passThroughStatus = error?.code !== 'OBJECT_NOT_FOUND' && typeof error?.status === 'number' && error.status >= 400 && error.status < 600; if (passThroughStatus) { - const safeMsg = typeof error.message === 'string' && error.message.length < 500 - ? error.message - : 'Request failed'; + // [#5423] Same bound as `mapDataError`'s 4xx passthrough, same cure — + // truncate rather than replace — but applied only to the 4xx half. + // + // This branch's range is 400-599, wider than `mapDataError`'s, and the + // 4xx/5xx split is the one distinction the repo already draws here: + // `mapDataError`'s sibling branch is "deliberately limited to 4xx: 5xx + // messages keep going through the sanitizing heuristics ... so + // internal/SQL details never reach the client verbatim". A 4xx message + // is addressed TO the caller and is the remedy; a 5xx message is a + // server fault's log diagnostic that happens to be reachable here. So + // 5xx keeps the wholesale replacement byte-for-byte — #5423 is about + // rejections a client is meant to read, and widening 5xx leniency is + // not in its scope. + const safeMsg = typeof error.message !== 'string' + ? 'Request failed' + : error.status < 500 + ? truncateClientMessage(error.message) + : error.message.length < CLIENT_MESSAGE_MAX + ? error.message + : 'Request failed'; return { status: error.status, body: { diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 58a2c2b8ec..a1917c8430 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2322,10 +2322,17 @@ describe('mapDataError — schema/constraint envelopes', () => { expect(r.body.code).not.toBe('FORBIDDEN'); }); - it('guards the passthrough message length (oversized → generic text)', () => { + // [#5423] This case used to assert the oversized message became the literal + // 'Request failed'. The BOUND is still the guard — an oversized message must + // not reach the client whole — but it is now enforced by truncating rather + // than by erasing the body text. Full coverage of the new disposition lives + // in `rest-4xx-message-truncation.test.ts`. + it('guards the passthrough message length (oversized → truncated, not erased)', () => { const r = mapDataError(Object.assign(new Error('x'.repeat(600)), { status: 403, code: 'FORBIDDEN' })); expect(r.status).toBe(403); - expect(r.body.error).toBe('Request failed'); + expect(r.body.error).not.toBe('Request failed'); + expect(String(r.body.error)).toHaveLength(500); + expect(String(r.body.error).endsWith('…')).toBe(true); }); it('keeps CONCURRENT_UPDATE 409 structured fields despite its own status property', () => {