diff --git a/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md new file mode 100644 index 00000000..803c9e0d --- /dev/null +++ b/.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md @@ -0,0 +1,87 @@ +# Validate the installed router contract and real navigation controls + +On 2026-10-27, the #186 router work started at dev +`8712389e24f567f5b3e6ed132c1ac5d4dafb21fa`. A fresh-cache production audit +reported five moderate entries: Router/DOM and UUID/federation/subgraph. The +[navigation advisory](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) and +[hydration advisory](https://github.com/advisories/GHSA-337j-9hxr-rhxg) both +identify 7.18.0 as fixed. The selected Router/DOM 7.18.4 retains React 18 and the +existing declarative BrowserRouter; its Node 20 minimum fits supported Node 22/24. +The [versioned migration guide](https://reactrouter.com/7.18.4/upgrading/v6) +was checked. No multi-segment splat route, data router or component-local lazy +page declaration was found in the live app routes. + +The first navigation test copied an older upstream normalization expectation. +Router 6.30.6 failed three mixed-separator cases; installed 7.18.4 rejected all four +external-looking destinations instead of navigating to a normalized internal +path. That four-case failure was a test-contract mismatch, not an application +regression. Inspection of the installed `validateNavigationTarget` established +the actual rejection boundary. The corrected control captures the thrown error +and requires the route to remain unchanged. Reinstalling the original locked 6.30.6 +then running the final control produced **4 failures and 1 history pass**; +the 7.18.4 control plus existing role guards produced **9 passes**. + +The first six actual-browser cases produced **4 failures and 2 passes**, with no +retries. The two failing assumptions on each viewport were that the sign-in +page always selected Password, and that Admin opened its Users tab. The current +UI preserves the sign-in method and opens Feedback & operations. The corrected +test explicitly selects Password and Users, retains the exact route/content +assertions, and checks that denied users cannot mount the administration heading. +History, lazy-page navigation, viewer guards and fresh-context callback routing +remain required. Final browser and integrated results belong in the PR. + +Corrective rule: use the installed consumer and current UI to establish a +regression contract, then run the exact final negative control against the old +locked consumer. Preserve the earlier failing logs; do not treat a test rewrite +or a successful install as proof that navigation remains usable. The lock +regression checks every Router/DOM resolution, not just the manifest range. + +Limits: MemoryRouter tests establish library navigation behavior, while the +actual-browser suite covers GraphDone routes at 1440px and 390px. No hostile +application-controlled destination, SSR hydration surface, live provider +callback, deployed revision or broad security clearance is claimed. Test tokens +are confined to disposable local/CI accounts; failure traces are not public +credential samples. Local evidence is retained in +`/tmp/graphdone-resolution-20261027/router-*` and this record travels with the fix. + +At frozen `3e5a761dc0b067ed3827dd29fca50d45fb5d3952`, the corrected browser suite +passed 6/6 without retries, the full web suite passed 340/340 and the harness +passed 167/167. Review found the inventory overstated the installed-version guard: +it checked locked ranges and installed-pair equality, which could accept two +equally stale installed copies. The guard now requires each actual web consumer +to satisfy the fixed range and match its resolved lock entry. The inventory +states that exact scope. Refresh the changed harness and final CI before merge. + +## Empty-account CI correction + +Composed Router head `14a4fcc` [CI 40584829418](https://github.com/GraphDone/GraphDone-Core/actions/runs/40584829418) +completed 50 browser cases in 854 seconds: 48 passed, two viewer cases failed, +plus the harness's failed-process row. This was not a process-budget timeout. +Retained screenshots show the authenticated empty workspace at both widths. +The desktop assertion incorrectly required a populated graph's `view-content`; +the phone login helper did not recognize an authenticated account without a +graph selector. The local fixture had graphs and hid both assumptions. + +Use the existing authenticated `workspace-content` container for account-level +route checks and keep login-form exclusion. Router cases additionally assert +`me.role === VIEWER` through the real API before and after the fresh-context +callback. A standalone real-browser control recognizes an empty workspace, +rejects a password form even with workspace markup, and rejects a loading page. +With the old helper, the empty-workspace positive failed deterministically; +this helper correction adds no retry, wait or timeout. Original CI artifacts +remain evidence, and final current-head browser/full CI gates still apply. + +The first empty-workspace/password-form negative used a zero-height empty main; +independent review measured it invisible, so that case could pass for the wrong +reason. It now retains visible content and asserts both the workspace and the +password field are visible before requiring an unauthenticated result. + +Gate capacity is separate from that failure: focus CI's 44-case new-user sequence +passed in 892 seconds, only eight below the 900-second process budget; UUID's +same suite took 768 seconds. Required LOD and route acceptance now run as separate +blocking sequences after the 42-case new-user suite. All 51 cases remain required +(42 existing, two LOD, seven route/helper controls), with unchanged per-action +and per-test deadlines, one worker and zero retries. CI runs and uploads each +result independently; pr/full profiles also include both new sequences. CLI +contract tests execute the enrollment/trace arguments. Do not describe this as +fixing the original two viewer failures or count superseded runs as passes. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 5476627d..f0c5a0d8 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| A router major update passes install but path handling or route UI assertions differ | [Validate installed navigation and current UI contracts](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); `routerNavigation.test.tsx`, `router-navigation.spec.ts`, dependency guard | | Status-note text lands in the title and Add remains disabled after quick edit opens | [Delayed autofocus must preserve chosen focus](../inbox/2026-10-27-delayed-autofocus-must-preserve-chosen-focus.md); `QuickEditSurface.test.tsx` controls the pending frame | | Minimap zoom or overview inspection is overwritten while a newly loaded graph settles | [Explicit camera input must stop automatic framing](../inbox/2026-10-27-explicit-camera-input-must-stop-auto-fit.md); timer-controlled `semantic-lod.spec.ts` negative control | | Dense-graph browser case exhausts its timeout after slow API fixture creation; cleanup errors disappear | [Bound fixture setup separately and fail on cleanup errors](../inbox/2026-10-27-cold-lod-fixtures-need-separate-budgets.md); `tests/e2e/graph/semantic-lod.spec.ts` | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index f1b6a2a7..8a387fd5 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -2,6 +2,7 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| Router advisory requires a major update and older upstream navigation expectations differ | [Installed consumer and route compatibility](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); every locked Router path plus desktop/phone route gates | | Fresh production audit flags Undici through optional SQLite build tooling without a direct application import | [Trace the native-build consumer and patch its actual resolved copy](../inbox/2026-10-24-undici-native-build-dependency.md); `tests/lib/dependencies/undici.test.mjs` rejects affected locked and installed consumer versions | | Green production audit hides development-tool findings or package counts are treated as exploit counts | [Audit scope lesson](../inbox/2026-10-23-dependency-audit-scope.md); [dated security scope](../../docs/quality/SECURITY_SCOPE_2026-10-23.md) | | Nested lint glob stalls the process | `tests/lib/dependencies/minimatch.test.mjs`; scoped parser matcher override and bounded child-process regression | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73eae2d3..e389bb08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,16 @@ jobs: env: TEST_URL: http://localhost:3127 CI: true + - name: Semantic LOD acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence semantic-lod --out test-artifacts/ci-semantic-lod + env: + TEST_URL: http://localhost:3127 + CI: true + - name: Route compatibility acceptance + run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence router-navigation --out test-artifacts/ci-router-navigation + env: + TEST_URL: http://localhost:3127 + CI: true - name: Mobile UI audit (layout / contrast / dialogs across every screen) run: node tests/run-unified.mjs --sequence mobile --out test-artifacts/ci-mobile env: diff --git a/docs/quality/SECURITY_SCOPE_2026-10-23.md b/docs/quality/SECURITY_SCOPE_2026-10-23.md index 5f212ff1..56f67cde 100644 --- a/docs/quality/SECURITY_SCOPE_2026-10-23.md +++ b/docs/quality/SECURITY_SCOPE_2026-10-23.md @@ -45,6 +45,29 @@ Follow-up implementation on 2026-10-23: [PR #169](https://github.com/GraphDone/G Source locations are relative to `packages/server/src/` and anchored to the snapshot above. Local, secret-free evidence is under `/tmp/graphdone-residual-*-audit.json`, `/tmp/graphdone-minimatch-*.log`, and `/tmp/graphdone-residual-auth-probe.log`; the latter reports only `parallelMagicLinkSuccesses: 2` and `arbitraryCaptchaAccepted: true`. +## Router correction on 2026-10-27 + +The dedicated #186 router change replaces Router/DOM 6.30.6 with 7.18.4, removes +the old `@remix-run/router` resolution and retains the existing declarative SPA. +Official advisories [GHSA-wrjc-x8rr-h8h6](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6) +and [GHSA-337j-9hxr-rhxg](https://github.com/advisories/GHSA-337j-9hxr-rhxg) +identify 7.18.0 as fixed. Existing app routes use fixed destinations, with no +observed SSR hydration path; upgrading is not a claim that a live exploit was +demonstrated. The DOM compatibility package is retained to keep imports stable. + +The dependency regression checks every locked path and the actual web consumers +against both the patched range and their resolved lock entries. Navigation +controls reject external-looking mixed separators and preserve internal query, +fragment and history behavior; existing role guards remain covered. Six required +browser cases exercise public and authenticated deep links, reload, lazy pages, +desktop links/phone navigation, back/forward, role rejection and a fresh-context +login callback. The callback uses an owned password-issued token and does not +establish provider/email authentication. Initial negative and fixture-contract +failures are retained in the [dated lesson](../../.agents/inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md). +Fresh full-suite, browser, audit and final composition results are recorded on +the reviewable PR; UUID, CSS evidence, tooling and hosted acceptance remain +separate work. + ## Coverage still needed The folder tests do not establish all generated Graph/WorkItem/Edge permissions, concurrent deletion integrity, MCP/agent grant revocation, share-link scopes, session/cookie policy, uploads, backups, email delivery, or deployed proxy controls. Those need isolated contracts and user-level tests with current roles, multiple accounts, races, and negative cases. Dependency counts, passing smoke tests, and absence of a discovered source sink are separate pieces of evidence; none is a full security assessment. diff --git a/package-lock.json b/package-lock.json index 6559eba8..213cef61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2584,15 +2584,6 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, - "node_modules/@remix-run/router": { - "version": "1.23.4", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", - "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "dev": true, @@ -11174,38 +11165,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-router": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", - "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", - "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.4", - "react-router": "6.30.6" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -11777,6 +11736,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz", + "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -15928,7 +15893,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.20.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", @@ -16331,6 +16296,19 @@ "node": "18 || 20 || >=22" } }, + "packages/web/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "packages/web/node_modules/eslint": { "version": "9.39.5", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", @@ -16531,6 +16509,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/web/node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "packages/web/node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "packages/web/node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", diff --git a/packages/web/package.json b/packages/web/package.json index f011b528..d2e4e70d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.20.0", + "react-router-dom": "^7.18.4", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", "tailwindcss": "^3.3.0", diff --git a/packages/web/src/lib/routerNavigation.test.tsx b/packages/web/src/lib/routerNavigation.test.tsx new file mode 100644 index 00000000..5244f71a --- /dev/null +++ b/packages/web/src/lib/routerNavigation.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useState } from 'react'; +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; + +function Navigation({ destination }: { destination: string }) { + const navigate = useNavigate(); + const location = useLocation(); + const [error, setError] = useState(''); + return <> + + + + {location.pathname}{location.search}{location.hash} + {error &&

{error}

} + + Home} /> + Settings} /> + + ; +} + +describe('installed router navigation contract', () => { + for (const prefix of ['//', '\\\\', '/\\', '\\/']) { + it(`rejects external-looking separators ${JSON.stringify(prefix)} before navigation`, async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open destination' })); + expect(await screen.findByRole('alert')).toHaveTextContent('External navigation is not allowed'); + expect(screen.getByRole('heading', { name: 'Home' })).toBeVisible(); + expect(screen.getByLabelText('Current location')).toHaveTextContent(/^\/home$/); + }); + } + + it('preserves query, fragment and browser-style back/forward history', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open destination' })); + await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account')); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByRole('heading', { name: 'Home' })).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Forward' })); + await waitFor(() => expect(screen.getByLabelText('Current location')).toHaveTextContent('/settings?tab=profile#account')); + }); +}); diff --git a/tests/e2e/auth/router-navigation.spec.ts b/tests/e2e/auth/router-navigation.spec.ts new file mode 100644 index 00000000..12fbf852 --- /dev/null +++ b/tests/e2e/auth/router-navigation.spec.ts @@ -0,0 +1,99 @@ +import { test, expect, type Page } from '@playwright/test'; +import { login, TEST_USERS, getBaseURL, getAuthState } from '../../lib/auth'; +import { gql } from '../../lib/graphRequest'; + +test('auth state recognizes an empty authenticated workspace and rejects a login form @router', async ({ page }) => { + await page.setContent('

No Graphs Available

'); + expect((await getAuthState(page)).isLoggedIn).toBe(true); + await page.setContent('

No Graphs Available

'); + await expect(page.getByTestId('workspace-content')).toBeVisible(); + await expect(page.locator('input[type="password"]')).toBeVisible(); + expect((await getAuthState(page)).isLoggedIn).toBe(false); + await page.setContent('
Loading
'); + expect((await getAuthState(page)).isLoggedIn).toBe(false); +}); + +for (const width of [1440, 390]) { + test.describe(`application route compatibility at ${width}px @router`, () => { + test.use({ viewport: { width, height: 900 }, isMobile: width === 390, hasTouch: width === 390 }); + test.describe.configure({ timeout: 90_000, retries: 0 }); + const errors = new WeakMap(); + test.beforeEach(async ({ page }) => { + errors.set(page, []); + page.on('pageerror', error => errors.get(page)!.push(error.message)); + await page.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); + }); + test.afterEach(async ({ page }) => expect(errors.get(page)).toEqual([])); + + test('public deep links, callback errors and unknown paths preserve route behavior', async ({ page }) => { + await page.goto(`${getBaseURL()}/forgot-password`); + await expect(page.getByRole('heading', { name: 'Reset Password', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Reset Password', exact: true })).toBeVisible(); + await page.goto(`${getBaseURL()}/reset-password?token=owned-invalid-router-fixture`); + await expect(page.getByRole('heading', { name: 'Set New Password' })).toBeVisible(); + await page.goto(`${getBaseURL()}/login?error=invalid_magic_link`); + await expect(page.getByText('This magic link is not valid or has already been used.', { exact: true })).toBeVisible(); + await page.goto(`${getBaseURL()}/owned-unknown-route/nested`); + await expect(page).toHaveURL(new URL('/', getBaseURL()).href); + await page.getByRole('tab', { name: 'Password', exact: true }).click(); + await expect(page.locator('input[name="emailOrUsername"]')).toBeVisible(); + }); + + test('deep routes, lazy page navigation and back/forward history remain usable', async ({ page }) => { + await login(page, TEST_USERS.ADMIN, { retries: 1 }); + await page.goto(`${getBaseURL()}/settings?tab=profile#account`); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + if (width === 390) { + await page.getByTestId('mobile-bottom-nav').getByText('More', { exact: true }).tap(); + await page.getByTestId('mobile-more-sheet').getByText('Analytics', { exact: true }).tap(); + } else { + await page.getByRole('link', { name: 'Analytics', exact: true }).click(); + } + await expect(page.getByRole('heading', { name: 'Your graph, in motion' })).toBeVisible(); + await expect(page).toHaveURL(new URL('/analytics', getBaseURL()).href); + await page.goBack(); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + await expect(page).toHaveURL(new URL('/settings?tab=profile#account', getBaseURL()).href); + await page.goForward(); + await expect(page.getByRole('heading', { name: 'Your graph, in motion' })).toBeVisible(); + await page.goto(`${getBaseURL()}/admin`); + await expect(page).toHaveURL(new URL('/admin', getBaseURL()).href); + await expect(page.getByRole('heading', { name: 'System Administration', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Users', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Create User', exact: true })).toBeVisible(); + }); + + test('viewer route guards and a fresh-context login callback preserve authorization', async ({ page, browser }) => { + await login(page, TEST_USERS.VIEWER, { retries: 1 }); + expect((await gql(page, '{ me { role } }')).me.role).toBe('VIEWER'); + for (const route of ['/admin', '/backend']) { + await page.goto(`${getBaseURL()}${route}`); + await expect(page).toHaveURL(new URL('/', getBaseURL()).href); + await expect(page.getByTestId('workspace-content')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'System Administration', exact: true })).toHaveCount(0); + } + const token = await page.evaluate(() => localStorage.getItem('authToken') || sessionStorage.getItem('authToken')); + expect(Boolean(token)).toBe(true); + const callbackContext = await browser.newContext({ viewport: { width, height: 900 }, isMobile: width === 390, hasTouch: width === 390, ignoreHTTPSErrors: true }); + try { + const callback = await callbackContext.newPage(); + const callbackErrors: string[] = []; + callback.on('pageerror', error => callbackErrors.push(error.message)); + await callback.addInitScript(() => localStorage.setItem('graphdone:viewMode', 'cards')); + await callback.goto(`${getBaseURL()}/login?token=${encodeURIComponent(token!)}`); + await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); + await expect(callback.getByTestId('workspace-content')).toBeVisible(); + expect((await gql(callback, '{ me { role } }')).me.role).toBe('VIEWER'); + expect(await callback.evaluate(() => !window.location.search.includes('token='))).toBe(true); + await callback.goto(`${getBaseURL()}/admin`); + await expect(callback).toHaveURL(new URL('/', getBaseURL()).href); + expect(callbackErrors).toEqual([]); + } finally { + await callbackContext.close(); + } + }); + }); +} diff --git a/tests/lib/auth.ts b/tests/lib/auth.ts index f03945a2..ade4f7a7 100644 --- a/tests/lib/auth.ts +++ b/tests/lib/auth.ts @@ -90,6 +90,7 @@ export async function getAuthState(page: Page): Promise { // Workspace-specific indicators '[data-testid="graph-selector"]', '[data-testid="graph-selector-mobile"]', + '[data-testid="workspace-content"]', '.graph-selector', 'text="Graph Viewer"', // Error indicators @@ -136,6 +137,7 @@ export async function getAuthState(page: Page): Promise { ind.includes('Logout') || ind.includes('user-menu') || ind.includes('graph-selector') || + ind.includes('workspace-content') || ind.includes('Graph Viewer') ); @@ -859,4 +861,4 @@ export async function waitForAuthentication( } throw new Error(`Authentication timeout after ${timeout}ms`); -} \ No newline at end of file +} diff --git a/tests/lib/dependencies/router.test.mjs b/tests/lib/dependencies/router.test.mjs new file mode 100644 index 00000000..6fc4d4d9 --- /dev/null +++ b/tests/lib/dependencies/router.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { relative, sep } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = new URL('../../../', import.meta.url); +const require = createRequire(new URL('package.json', root)); +const webRequire = createRequire(new URL('packages/web/package.json', root)); +const semver = require('semver'); + +test('all locked React Router paths contain the 7.18 security fixes', async () => { + const lock = JSON.parse(await readFile(new URL('package-lock.json', root), 'utf8')); + const paths = Object.entries(lock.packages).filter(([path]) => /(^|\/)node_modules\/react-router(?:-dom)?$/.test(path)); + assert.ok(paths.length >= 2); + for (const [path, entry] of paths) { + assert.ok(semver.satisfies(entry.version, '>=7.18.0', { includePrerelease: true }), `${path}: ${entry.version}`); + } + for (const name of ['react-router', 'react-router-dom']) { + const installed = webRequire(`${name}/package.json`).version; + const path = relative(fileURLToPath(root), webRequire.resolve(`${name}/package.json`)).split(sep).join('/').replace(/\/package\.json$/, ''); + assert.ok(semver.satisfies(installed, '>=7.18.0', { includePrerelease: true }), `${name}: installed ${installed}`); + assert.equal(installed, lock.packages[path]?.version, `${name}: installed consumer matches lock`); + } + assert.equal(webRequire('react-router-dom/package.json').version, webRequire('react-router/package.json').version); +}); diff --git a/tests/lib/runner/unified-cli.test.mjs b/tests/lib/runner/unified-cli.test.mjs index 36d2bf23..8e56a277 100644 --- a/tests/lib/runner/unified-cli.test.mjs +++ b/tests/lib/runner/unified-cli.test.mjs @@ -37,7 +37,7 @@ function fixture(t) { return { dir, run }; } -for (const sequence of ['new-user', 'mobile', 'perf-budgets']) { +for (const sequence of ['new-user', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets']) { test(`CLI forwards failure-only tracing without retries for ${sequence}`, t => { const { dir, run } = fixture(t); const report = { suites: [{ specs: [{ title: 'fixture', ok: true, @@ -47,7 +47,18 @@ for (const sequence of ['new-user', 'mobile', 'perf-budgets']) { const result = run(['--sequence', sequence]); assert.equal(result.status, 0, result.stdout); const args = JSON.parse(readFileSync(join(dir, 'bin/argv.json'))); - if (sequence === 'new-user') assert.ok(args.includes('tests/e2e/graph/semantic-lod.spec.ts')); + if (sequence === 'new-user') { + assert.ok(!args.includes('tests/e2e/graph/semantic-lod.spec.ts')); + assert.ok(!args.includes('tests/e2e/auth/router-navigation.spec.ts')); + } + if (sequence === 'semantic-lod' || sequence === 'router-navigation') { + const spec = sequence === 'semantic-lod' ? 'tests/e2e/graph/semantic-lod.spec.ts' : 'tests/e2e/auth/router-navigation.spec.ts'; + assert.ok(args.includes(spec)); + assert.equal(SEQUENCES[sequence].blocking, true); + const workflow = readFileSync(join(root, '.github/workflows/ci.yml'), 'utf8'); + const command = `run: xvfb-run --auto-servernum node tests/run-unified.mjs --sequence ${sequence} --out test-artifacts/ci-${sequence}`; + assert.equal(workflow.split(command).length, 2); + } assert.deepEqual(args.filter(arg => arg.startsWith('--trace=')), ['--trace=retain-on-failure']); assert.deepEqual(args.filter(arg => arg.startsWith('--retries=')), ['--retries=0']); assert.deepEqual(args.filter(arg => arg.startsWith('--workers=')), ['--workers=1']); diff --git a/tests/sequences/unified.config.mjs b/tests/sequences/unified.config.mjs index 1cd4e35a..6f72a0cc 100644 --- a/tests/sequences/unified.config.mjs +++ b/tests/sequences/unified.config.mjs @@ -22,7 +22,6 @@ export const SEQUENCES = { 'tests/e2e/graph/overview-board-actions.spec.ts', 'tests/e2e/graph/recorded-ai-work.spec.ts', 'tests/e2e/graph/admin-dialog-responsive.spec.ts', - 'tests/e2e/graph/semantic-lod.spec.ts', 'tests/e2e/smoke/user-smoke.spec.ts', 'tests/e2e/quality/persona-lifecycle.spec.ts', 'tests/e2e/quality/graph-motion.spec.ts', @@ -30,6 +29,12 @@ export const SEQUENCES = { 'tests/e2e/quality/graph-details.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', ], blocking: true }, + 'semantic-lod': { adapter: 'playwright', title: 'Semantic LOD acceptance', args: [ + 'tests/e2e/graph/semantic-lod.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, + 'router-navigation': { adapter: 'playwright', title: 'Route compatibility acceptance', args: [ + 'tests/e2e/auth/router-navigation.spec.ts', `--project=${PW}`, '--headed', '--workers=1', '--retries=0', '--trace=retain-on-failure', + ], blocking: true }, 'e2e-auth': { adapter: 'playwright', title: 'Auth e2e — passwordless focus', args: ['tests/e2e/auth/passwordless-focus.spec.ts', `--project=${PW}`], blocking: true }, 'e2e-graph': { adapter: 'playwright', title: 'Graph e2e — camera', args: ['tests/e2e/graph/camera.spec.ts', `--project=${PW}`], blocking: true }, 'mobile': { adapter: 'playwright', title: 'Mobile audit', args: ['--grep', '@mobile|@audit', `--project=${PW}`, '--workers=1', '--retries=0', '--trace=retain-on-failure'], blocking: true }, @@ -51,8 +56,8 @@ export const SEQUENCES = { export const PROFILES = { smoke: ['unit-web', 'smoke', 'e2e-auth'], - pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets'], - full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], + pr: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets'], + full: ['unit-web', 'smoke', 'e2e-auth', 'e2e-graph', 'semantic-lod', 'router-navigation', 'mobile', 'perf-budgets', 'diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], report: ['diagnostics', 'showcase', 'matrix', 'vlm', 'perf-scale', 'cloud-audit'], live: ['live-journey'], };