From 0e92beed95c535dc50a2de82e3d93dfaed8296d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:39:18 +0000 Subject: [PATCH] test(network-escape): serve batch 1's five probes from doubles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 1 of the objectui#7307 burn-down: the `/api/v1/security/explain` family outside app-shell and plugin-detail, plus the one plugin-charts `/api/v1/meta/object/task` row. Five files stop opening a real socket, and their lines leave `KNOWN_ESCAPES` and `PINNED_LEDGER` together. Each escape was traced to its call site with a stack probe on the guard's attribution point rather than inferred: catalog-gallery-render / ObjectView.namedViewSortArity / bulkDeleteVisibleWhen -> ObjectGrid -> useRecordCrudVerdicts:199 ObjectCalendar.navWidthDefault -> RecordDetailDrawer -> useRecordEditable:75 ObjectChart.heightChain -> ObjectChart.tsx:390 -> loadObjectSchema All five take the same `apiFetch ?? fetch` fallback, so the double is one shape per endpoint in the shape objectui#5225 landed (`vi.stubGlobal` + `cleanup()` before `vi.unstubAllGlobals()`, objectui#7439's ordering): a RECORDING router, not a blanket stub — `afterEach` fails on any URL that is not the route it serves, so a new escape reds instead of vanishing into the hook's best-effort `catch`. The explain double answers the permissive verdict in the two response shapes the two hooks read. That changes no assertion: `useRecordEditable` initialises `allowed` to `true` and its failure path leaves it there, and the only consumer of the batched lookup is `resolveRowRecordCrudAffordance`, whose rule is `recordVerdict !== false` — so `true` and the absent verdict the failing request produced are the same value at every read site. Ledger: 21 -> 16 in both lists, in lockstep. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM7kaS4dPpYHV5BsMyu4tQ --- .changeset/network-escape-batch1.md | 4 + .../test/catalog-gallery-render.test.tsx | 93 ++++++++++++++++++- .../ObjectCalendar.navWidthDefault.test.tsx | 85 ++++++++++++++++- .../src/ObjectChart.heightChain.test.tsx | 74 ++++++++++++++- .../__tests__/bulkDeleteVisibleWhen.test.tsx | 81 ++++++++++++++++ .../ObjectView.namedViewSortArity.test.tsx | 92 +++++++++++++++++- .../__tests__/network-escape-ledger.test.ts | 7 +- vitest.setup.network-escape-guard.ts | 17 +--- 8 files changed, 428 insertions(+), 25 deletions(-) create mode 100644 .changeset/network-escape-batch1.md diff --git a/.changeset/network-escape-batch1.md b/.changeset/network-escape-batch1.md new file mode 100644 index 000000000..7473843dc --- /dev/null +++ b/.changeset/network-escape-batch1.md @@ -0,0 +1,4 @@ +--- +--- + +Test-only change: the five network-escape files in batch 1 of objectui#7307 now serve their `/api/v1/security/explain` and `/api/v1/meta/object/` probes from a recording double instead of a real socket, and their lines leave `KNOWN_ESCAPES` and `PINNED_LEDGER` together. No published behaviour changes — no product source is touched. diff --git a/examples/schema-catalog/test/catalog-gallery-render.test.tsx b/examples/schema-catalog/test/catalog-gallery-render.test.tsx index c880a03b8..ad12fa98a 100644 --- a/examples/schema-catalog/test/catalog-gallery-render.test.tsx +++ b/examples/schema-catalog/test/catalog-gallery-render.test.tsx @@ -125,8 +125,8 @@ * entries render `role="alert"` because that is what an Alert IS — the * assertion is correct for a dashboard tile and wrong for the corpus. */ -import { describe, it, expect, afterAll } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; import '@object-ui/components'; // Mirrors apps/site/app/components/registerCatalogBlocks.ts, in its order. import '@object-ui/plugin-dashboard'; @@ -517,6 +517,95 @@ function authoredTitles(node: unknown, acc: string[] = []): string[] { const entries = allExamples(); const occurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1; +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point: + * + * the grid-bearing tiles (`object-grid`, and `object-view` around it) + * -> ObjectGrid packages/plugin-grid/src/ObjectGrid.tsx:1407 + * -> useRecordCrudVerdicts packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts:199 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (batched, `recordIds` per page) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The gallery harness below supplies a `dataSource` on `SchemaRendererContext` but no `apiFetch`, so the fallback is taken. The read is best-effort (a network or parse failure leaves the verdict map empty — fail open), which is why the sweep stayed green while 18 requests per run always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on and + * `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` + * carries. Deliberately NOT a blanket network stub: it records every URL it is + * handed and `afterEach` fails on any URL that is not the explain route, so an + * escape to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two hooks read (ADR-0090 D6 / + * ADR-0095 C2) — `{ record: { visible } }` for a single `recordId`, + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, and the ONLY consumer of the batched lookup is + * `resolveRowRecordCrudAffordance`, whose rule is `recordVerdict !== false` — + * so `true` and the absent verdict the failing request produced are the same + * value at every read site. This file asserts that each tile drew something other than a diagnostic panel; no tile's DOM is derived from the verdict. + * ──────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + +beforeEach(() => { + installExplainDouble(); +}); + +afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); +}); + describe('objectui#4616 — every catalog entry renders in the docs gallery', () => { /** * NON-VACUITY CONTROL for the sweep as a whole. `it.each([])` reports nothing diff --git a/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx index f03ca1ff0..6146d87ea 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx @@ -114,12 +114,95 @@ function readPanelWidth(): string { return panel!.style.maxWidth; } +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point: + * + * RecordDetailDrawer (the drawer this file opens) + * -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:75 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the record editable — fail open), which is why the three cases below stayed green while the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on and + * `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` + * carries. Deliberately NOT a blanket network stub: it records every URL it is + * handed and `afterEach` fails on any URL that is not the explain route, so an + * escape to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two hooks read (ADR-0090 D6 / + * ADR-0095 C2) — `{ record: { visible } }` for a single `recordId`, + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, and the ONLY consumer of the batched lookup is + * `resolveRowRecordCrudAffordance`, whose rule is `recordVerdict !== false` — + * so `true` and the absent verdict the failing request produced are the same + * value at every read site. The drawer's width — everything this file asserts — is not derived from the verdict at all. + * ──────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + describe('calendar drawer width with no declared `navigation` (objectui#6303)', () => { beforeEach(() => { drawerProps = null; try { window.localStorage.clear(); } catch { /* ignore */ } + installExplainDouble(); + }); + afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); }); - afterEach(() => cleanup()); it('half 1: the calendar injects no width of its own (so the drawer default applies)', async () => { await openDrawer(); diff --git a/packages/plugin-charts/src/ObjectChart.heightChain.test.tsx b/packages/plugin-charts/src/ObjectChart.heightChain.test.tsx index d089c1a66..0de117827 100644 --- a/packages/plugin-charts/src/ObjectChart.heightChain.test.tsx +++ b/packages/plugin-charts/src/ObjectChart.heightChain.test.tsx @@ -22,7 +22,7 @@ */ import React from 'react'; -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; vi.mock('./ChartRenderer', () => ({ @@ -31,8 +31,80 @@ vi.mock('./ChartRenderer', () => ({ import { ObjectChart } from './ObjectChart'; +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/meta/object/task` escape, served here. + * + * Nothing below asks for metadata, yet every run opened a REAL TCP connection + * to `http://localhost:3000`. Traced with a stack probe on the network-escape + * guard's attribution point: + * + * ObjectChart (option-colour effect) packages/plugin-charts/src/ObjectChart.tsx:390 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * -> loadObjectSchema ObjectChart.tsx:411 + * -> loadDimensionFieldMeta packages/core/src/utils/chart-series.ts + * GET /api/v1/meta/object/task + * + * That effect reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no `SchemaRendererProvider` in this tree, + * degrades to the GLOBAL `fetch` by design — a standalone embed must keep + * rendering rather than crash. Under happy-dom that global is a real HTTP + * client and the document URL defaults to `http://localhost:3000`, so the + * relative path resolved to a live request. The read is best-effort (every + * failure leaves `optionMeta` null and the chart on the theme palette), which + * is why the assertion below stayed green while the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on and + * `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` + * carries. Deliberately NOT a blanket network stub: it records every URL it is + * handed and `afterEach` fails on any URL that is not the metadata route, so an + * escape to somewhere else reds here instead of vanishing into that `catch`. + * + * The served document declares no fields, so nothing resolves and `optionMeta` + * settles null — the state the failing request already produced. The height + * assertion cannot see it either way: `ChartRenderer` is mocked to null. + * ──────────────────────────────────────────────────────────────────────────── */ + +const META_OBJECT_ROUTE = /^\/api\/v1\/meta\/object\/(.+)$/; + +/** Every URL this render handed the global `fetch`, in request order. */ +let metaCalls: string[] = []; + +/** Serve `/api/v1/meta/object/` with a field-less doc; record everything. */ +function installMetaObjectDouble() { + metaCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + metaCalls.push(url); + const m = META_OBJECT_ROUTE.exec(url); + if (!m) return { ok: false, status: 404, json: async () => ({}) }; + return { + ok: true, + status: 200, + json: async () => ({ item: { name: decodeURIComponent(m[1]), fields: {} } }), + }; + }), + ); +} + +beforeEach(() => { + installMetaObjectDouble(); +}); + afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the effect's best-effort `catch`. + expect(metaCalls.filter((url) => !META_OBJECT_ROUTE.test(url))).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a metadata effect settling in that window + // escapes again (objectui#7439). cleanup(); + vi.unstubAllGlobals(); }); describe('ObjectChart wrapper height chain (objectui#5451)', () => { diff --git a/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx b/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx index 2ce75c3ac..50d583449 100644 --- a/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx +++ b/packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx @@ -155,12 +155,93 @@ async function renderAndSelect( return harness; } +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point: + * + * ObjectGrid packages/plugin-grid/src/ObjectGrid.tsx:1407 + * -> useRecordCrudVerdicts packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts:199 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (batched, `recordIds` per page) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the verdict map empty — fail open), which is why the four cases below stayed green while the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on and + * `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` + * carries. Deliberately NOT a blanket network stub: it records every URL it is + * handed and `afterEach` fails on any URL that is not the explain route, so an + * escape to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two hooks read (ADR-0090 D6 / + * ADR-0095 C2) — `{ record: { visible } }` for a single `recordId`, + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, and the ONLY consumer of the batched lookup is + * `resolveRowRecordCrudAffordance`, whose rule is `recordVerdict !== false` — + * so `true` and the absent verdict the failing request produced are the same + * value at every read site. The subject of this file is the OBJECT's declared `userActions.delete.visibleWhen` predicate, a layer above the record verdict and evaluated without it. + * ──────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + beforeEach(() => { vi.clearAllMocks(); + installExplainDouble(); }); afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). cleanup(); + vi.unstubAllGlobals(); }); describe('selection-bar Delete vs `userActions.delete.visibleWhen` (objectui#4420)', () => { diff --git a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx index 0d1bd25b7..370b96b72 100644 --- a/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx +++ b/packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx @@ -45,8 +45,8 @@ * read green. */ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; @@ -120,6 +120,94 @@ const headerCell = (container: HTMLElement, label: string) => th.textContent?.includes(label), ) as HTMLElement; +/* ──────────────────────────────────────────────────────────────────────────── + * objectui#7307 — this file's `/api/v1/security/explain` escape, served here. + * + * Nothing below asks for a security verdict, yet every run opened a REAL TCP + * connection to `http://localhost:3000`. Traced with a stack probe on the + * network-escape guard's attribution point: + * + * ObjectView -> ObjectGrid packages/plugin-grid/src/ObjectGrid.tsx:1407 + * -> useRecordCrudVerdicts packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts:199 + * -> `const doFetch = apiFetch ?? fetch` <- the escape + * POST /api/v1/security/explain (batched, `recordIds` per page) + * + * The hook reads the host's AUTHENTICATED `apiFetch` off + * `SchemaRendererContext` and, with no host supplying one, degrades to the + * GLOBAL `fetch` by design — a standalone embed must keep rendering rather than + * crash. Under happy-dom that global is a real HTTP client and the document URL + * defaults to `http://localhost:3000`, so the relative path resolved to a live + * request. The read is best-effort (a network or parse failure leaves the verdict map empty — fail open), which is why the four cases below stayed green while the request always failed. + * + * Answered from a RECORDING double — the shape objectui#5225 settled on and + * `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` + * carries. Deliberately NOT a blanket network stub: it records every URL it is + * handed and `afterEach` fails on any URL that is not the explain route, so an + * escape to somewhere else reds here instead of vanishing into that `catch`. + * + * What it answers, and why that changes no assertion here: the permissive + * verdict, in the two response shapes the two hooks read (ADR-0090 D6 / + * ADR-0095 C2) — `{ record: { visible } }` for a single `recordId`, + * `{ records: [{ recordId, visible }] }` for a batched `recordIds`. + * `useRecordEditable` initialises `allowed` to `true` and its failure path + * leaves it there, and the ONLY consumer of the batched lookup is + * `resolveRowRecordCrudAffordance`, whose rule is `recordVerdict !== false` — + * so `true` and the absent verdict the failing request produced are the same + * value at every read site. The sort this file measures reaches the grid through the view's `listViews` entry and leaves as `$orderby`; no verdict touches that path. + * ──────────────────────────────────────────────────────────────────────────── */ + +const EXPLAIN_ROUTE = '/api/v1/security/explain'; + +/** Every URL this render handed the global `fetch`, in request order. */ +let explainCalls: string[] = []; + +/** Serve `POST /api/v1/security/explain` permissively; record everything. */ +function installExplainDouble() { + explainCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + explainCalls.push(url); + if (url !== EXPLAIN_ROUTE) return { ok: false, status: 404, json: async () => ({}) }; + let body: { recordId?: unknown; recordIds?: unknown } = {}; + try { + body = JSON.parse(String((init as { body?: unknown } | undefined)?.body ?? '{}')); + } catch { + /* a non-JSON body is not a request this route can answer */ + } + const recordIds = Array.isArray(body.recordIds) ? body.recordIds : null; + return { + ok: true, + status: 200, + json: async () => + recordIds + ? { records: recordIds.map((recordId) => ({ recordId, visible: true })) } + : { record: { visible: true } }, + }; + }), + ); +} + +beforeEach(() => { + installExplainDouble(); +}); + +afterEach(() => { + // The double is a router, not a sink: an escape to any OTHER endpoint fails + // here instead of vanishing into the hook's best-effort `catch`. + expect(explainCalls.filter((url) => url !== EXPLAIN_ROUTE)).toEqual([]); + // Unmount BEFORE restoring the real `fetch`. Vitest runs `afterEach` hooks in + // reverse registration order, so this file's teardown runs before the root + // setup's RTL cleanup: unstubbing first would leave the tree mounted with the + // real global back in place, and a verdict effect settling in that window + // escapes again (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); +}); + describe("objectui#5270 — a named view's sort reaches the grid", () => { it('draws the declared sort indicator before anyone clicks', async () => { // Half one. The array used to be re-wrapped to `[[…]]` and parsed to `[]`, diff --git a/scripts/__tests__/network-escape-ledger.test.ts b/scripts/__tests__/network-escape-ledger.test.ts index 44f016b5f..9b7374821 100644 --- a/scripts/__tests__/network-escape-ledger.test.ts +++ b/scripts/__tests__/network-escape-ledger.test.ts @@ -31,7 +31,7 @@ import { KNOWN_ESCAPES } from '../../vitest.setup.network-escape-guard'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); /** - * The 21 files measured escaping on `67dadd6`, pinned verbatim. + * What remains of the 21 files measured escaping on `67dadd6`, pinned verbatim. * * Provenance: a full sweep of every Vitest project (`dom` all 8 shards, * `dom-heavy`, `unit`, `apps/console`) with an attribution ledger wrapping @@ -39,7 +39,6 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../ * and must be done in lockstep with `KNOWN_ESCAPES`. */ const PINNED_LEDGER: readonly string[] = [ - 'examples/schema-catalog/test/catalog-gallery-render.test.tsx', 'packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx', 'packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx', 'packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx', @@ -48,18 +47,14 @@ const PINNED_LEDGER: readonly string[] = [ 'packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx', 'packages/app-shell/src/views/studio-design/StudioDesignSurface.designerRegistryMissing.test.tsx', 'packages/app-shell/src/views/studio-design/__tests__/studioSurfaceContext.test.tsx', - 'packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx', - 'packages/plugin-charts/src/ObjectChart.heightChain.test.tsx', 'packages/plugin-detail/src/__tests__/defaultFieldGroupsPage.sectionHeadings.test.tsx', 'packages/plugin-detail/src/__tests__/guideCrudAppRenders.test.tsx', 'packages/plugin-detail/src/__tests__/recordDetailsBodySource.test.tsx', 'packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx', 'packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx', - 'packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx', 'packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx', 'packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx', 'packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx', - 'packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx', ]; describe('network-escape ledger (objectui#6640) is shrink-only', () => { diff --git a/vitest.setup.network-escape-guard.ts b/vitest.setup.network-escape-guard.ts index f8ef9c64c..91fb6e50a 100644 --- a/vitest.setup.network-escape-guard.ts +++ b/vitest.setup.network-escape-guard.ts @@ -47,7 +47,8 @@ * * ## The burn-down list * - * `KNOWN_ESCAPES` is the 21 files measured on `67dadd6`. They are not excused: + * `KNOWN_ESCAPES` is what REMAINS of the 21 files measured on `67dadd6` + * (objectui#7307 is burning them down batch by batch). They are not excused: * each still emits, and now prints an ATTRIBUTED line naming itself, so a * reader who meets a bare stack in a truncated run can tell whose it is. The * list may only SHRINK — enforced mechanically by the reconcile pin in @@ -108,12 +109,10 @@ function writeStderr(message: string): void { const ESCAPE_ORIGIN = /^https?:\/\/(?:127\.0\.0\.1|localhost):3000(?:\/|$)/; /** - * Files measured escaping on 67dadd6 (objectui#6640). ONLY SHRINKS. - * The comment on each line is the endpoint it reached. + * What remains of the files measured escaping on 67dadd6 (objectui#6640). + * ONLY SHRINKS. The comment on each line is the endpoint it reached. */ export const KNOWN_ESCAPES: ReadonlySet = new Set([ - // /api/v1/security/explain - 'examples/schema-catalog/test/catalog-gallery-render.test.tsx', // /api/v1/meta/_drafts 'packages/app-shell/src/console/home/__tests__/HomePage.approvalsTarget.test.tsx', // /api/v1/meta/_drafts @@ -131,10 +130,6 @@ export const KNOWN_ESCAPES: ReadonlySet = new Set([ // /api/v1/ai/conversations 'packages/app-shell/src/views/studio-design/__tests__/studioSurfaceContext.test.tsx', // /api/v1/security/explain - 'packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx', - // /api/v1/meta/object/task - 'packages/plugin-charts/src/ObjectChart.heightChain.test.tsx', - // /api/v1/security/explain 'packages/plugin-detail/src/__tests__/defaultFieldGroupsPage.sectionHeadings.test.tsx', // /api/task/42, /api/v1/security/explain 'packages/plugin-detail/src/__tests__/guideCrudAppRenders.test.tsx', @@ -145,15 +140,11 @@ export const KNOWN_ESCAPES: ReadonlySet = new Set([ // /api/v1/security/explain 'packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx', // /api/v1/security/explain - 'packages/plugin-grid/src/__tests__/bulkDeleteVisibleWhen.test.tsx', - // /api/v1/security/explain 'packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx', // /api/v1/security/explain 'packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx', // /api/v1/security/explain 'packages/plugin-kanban/src/ObjectKanban.overlayTitleNoProviderFallback.test.tsx', - // /api/v1/security/explain - 'packages/plugin-view/src/__tests__/ObjectView.namedViewSortArity.test.tsx', ]); type Escape = { file: string; test: string; url: string };