Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/network-escape-batch2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Test-only change: the four network-escape files in batch 2 of objectui#7307 (three in `plugin-kanban`, one in `plugin-gantt`) now serve their `/api/v1/security/explain` probe 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.
95 changes: 93 additions & 2 deletions packages/plugin-gantt/src/ObjectGantt.navWidthDefault.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
* renders", either of which passes in both worlds.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ObjectGantt } from './ObjectGantt';

/** The width the gantt's drawer has always resolved to. Must not drift. */
Expand Down Expand Up @@ -79,12 +79,103 @@ async function openDrawer() {
await waitFor(() => expect(drawerProps).not.toBeNull());
}

/* ─────────────────────────────────────────────────────────────────────────────
* 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 (measured, not inferred):
*
* RecordDetailDrawer (the drawer this file opens)
* -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296
* -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76
* -> `const doFetch = apiFetch ?? fetch` <- the escape
* POST /api/v1/security/explain (twice per open: edit, then delete)
*
* 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 cases below stayed green while
* the request always failed.
*
* Answered from a RECORDING double — the shape objectui#5225 settled on, carried
* by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and
* by this batch's sibling
* `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`.
* 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 explain hooks read —
* `{ record: { visible } }` for a single `recordId`, and
* `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the
* FIRST is reached from this file (every call measured above comes from
* `useRecordEditable`); the batched branch is kept so this router stays
* byte-identical to its siblings rather than forking per file.
* `useRecordEditable` initialises `allowed` to `true` and its failure path
* leaves it there, 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('gantt drawer width with no declared `navigation`', () => {
beforeEach(() => {
drawerProps = null;
// Cross-test leakage guard: the drawer prefers a drag-resized width
// persisted in localStorage over its prop, which would mask half 2.
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();
});

it('half 1: the gantt injects no width of its own (so the drawer default applies)', async () => {
Expand Down
92 changes: 91 additions & 1 deletion packages/plugin-kanban/src/ObjectKanban.navWidthDefault.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,102 @@ 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 (measured, not inferred):
*
* RecordDetailDrawer (the drawer this file opens)
* -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296
* -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76
* -> `const doFetch = apiFetch ?? fetch` <- the escape
* POST /api/v1/security/explain (twice per open: edit, then delete)
*
* 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 cases below stayed green while
* the request always failed.
*
* Answered from a RECORDING double — the shape objectui#5225 settled on, carried
* by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and
* by this batch's sibling
* `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`.
* 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 explain hooks read —
* `{ record: { visible } }` for a single `recordId`, and
* `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the
* FIRST is reached from this file (every call measured above comes from
* `useRecordEditable`); the batched branch is kept so this router stays
* byte-identical to its siblings rather than forking per file.
* `useRecordEditable` initialises `allowed` to `true` and its failure path
* leaves it there, 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('kanban 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 kanban injects no width of its own (so the drawer default applies)', async () => {
await openDrawer();
Expand Down
95 changes: 93 additions & 2 deletions packages/plugin-kanban/src/ObjectKanban.overlayTitleI18n.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
*/

import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { I18nProvider } from '@object-ui/i18n';
Expand Down Expand Up @@ -109,7 +109,98 @@ async function openDrawer() {
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
}

afterEach(() => cleanup());
/* ─────────────────────────────────────────────────────────────────────────────
* 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 (measured, not inferred):
*
* RecordDetailDrawer (the drawer this file opens)
* -> DetailView packages/plugin-detail/src/DetailView.tsx:290,296
* -> useRecordEditable packages/plugin-detail/src/useRecordEditable.ts:76
* -> `const doFetch = apiFetch ?? fetch` <- the escape
* POST /api/v1/security/explain (twice per open: edit, then delete)
*
* 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 cases below stayed green while
* the request always failed.
*
* Answered from a RECORDING double — the shape objectui#5225 settled on, carried
* by `packages/plugin-report/src/__tests__/DatasetReportRenderer.test.tsx` and
* by this batch's sibling
* `packages/plugin-calendar/src/ObjectCalendar.navWidthDefault.test.tsx`.
* 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 explain hooks read —
* `{ record: { visible } }` for a single `recordId`, and
* `{ records: [{ recordId, visible }] }` for a batched `recordIds`. Only the
* FIRST is reached from this file (every call measured above comes from
* `useRecordEditable`); the batched branch is kept so this router stays
* byte-identical to its siblings rather than forking per file.
* `useRecordEditable` initialises `allowed` to `true` and its failure path
* leaves it there, so `true` and the absent verdict the failing request
* produced are the same value at every read site. The drawer's accessible
* name — 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 } },
};
}),
);
}

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('ObjectKanban record-detail drawer heading (objectui#3459)', () => {
it('names the drawer in English under an en session', async () => {
Expand Down
Loading
Loading