diff --git a/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md b/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md new file mode 100644 index 0000000..b6765f0 --- /dev/null +++ b/docs/redesign/phase-5/p5-15/p5-15-01/analytics-auth-repair/README.md @@ -0,0 +1,24 @@ +# P5-15-01R-03 — Analytics authentication return-path repair + +Source: `339e3ba591c91dcd3590d4cbc451ad31268f1aec`, development `codex/ui-redesign-phase4`. This checkpoint owns AUTH-01 and directly related return-target validation only. Production `main` remains unchanged and **NO-GO**. No Analytics data model, saved-search/Rules implementation, P5-15-02, P5-13, Docker, B07 or deployment work is authorized here. + +## Before-fix native evidence and demonstrated cause + +The unchanged original `functional-acceptance.spec.ts` anonymous case was rerun against the disposable Development/SQLite API and actual local sign-in on 2026-10-10. All route census steps complete; the assertion at line154 fails: requested `/analytics?months=6`, returned `/analytics`. The native run has zero passes/one failure, zero skipped/flaky/retries. The trace, screenshot and JSON are retained under ignored `.artifacts/p5-15-01R-03/browser/auth-original-red*`; portable final evidence follows validation. This is the original desired-state assertion, not a weakened replacement. + +`App.Shell` defines `path = location.pathname` and sends only that value as `state.from` when redirecting to the public landing page. Search and hash are lost at this boundary, before sign-in. Landing forwards the same router state to Login. Login already uses the supplied string for local, two-factor, Google and Microsoft completion; Analytics receives a query-free destination and chooses its existing twelve-month default. Its only supported reporting query is `months`:6 or24, otherwise12. No date/status/view controls are invented. + +The original Login slash-only validation also permits raw control characters, malformed encoded paths and normalized auth-entry destinations. Local URL normalization shows the literal-tab target `/\t/evil.example` can become a different origin; this is a demonstrated validation gap, **not** a demonstrated native external redirect or tenant exposure. Installed router replace handling may reject it with a history SecurityError. The bounded repair must choose the existing safe dashboard fallback instead. + +Before application changes, the Login component regression run has **17passing/12failing out of29**. Sixteen cases are new:12 untrusted target variants plus valid encoded URL, valid existing Jobs context, unsafe nested context and account-disposal entry. Existing Login cases remain passing; failures demonstrate missing safe fallback/context handling. JSON and log are retained separately; the initial shell wrapper incorrectly reported exit0, so the test JSON/failed assertions, not that wrapper exit, are authoritative RED evidence. + +## Bounded execution and invariants + +1. Retain the genuine native RED and add browser/local return-boundary regressions; commit/push this independently before application edits. +2. Preserve pathname/search/hash and valid existing Jobs return context at the existing App/Login owners. Validate untrusted destinations before every existing completion path; no new persistent redirect store or provider contract. +3. Exercise native Analytics periods/encoding/defaults/refresh/history, failed-login retry, expiry/logout, owned/foreign resources and unsafe client state. Keep saved searches, Rules, P2-D01, revision/fidelity and Production withholding green. +4. Run full fresh validation, reconcile57 groups without promoting incomplete groups to PASS, record evidence and immediately commit/push each completed unit. + +Existing session/account invalidation intentionally disposes the previous page/providers and replaces with `/login?sessionChanged=1`; previous-account private context must remain discarded. Backend ownership and role checks remain authoritative. Synthetic accounts/disposable SQLite only; no real external identity provider or production data. + +Current repair status: **IN PROGRESS**. Original AUTH-01 remains open until GREEN and full validation. P5-15-02 and production are not cleared. Broader human/device/font qualification, historical preference500 causes, the CV Unsaved intermittent observation and P5-13 deferral remain open. diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 07c0783..90a7063 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -31,9 +31,9 @@ const mockedApi = api as jest.Mocked; let consoleErrorSpy: jest.SpyInstance; -function renderLoginPage(initialMode: "login" | "register" = "login", from?: string) { +function renderLoginPage(initialMode: "login" | "register" = "login", from?: string, fromState?: unknown, search = "") { return render( - + @@ -227,6 +227,58 @@ describe('LoginPage', () => { await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); }); + it.each([ + 'https://evil.example/analytics', '//evil.example/analytics', '/\t/evil.example', + '/%2f%2fevil.example', '/%252f%252fevil.example', '/%5cevil.example', + '/analytics?months=%ZZ', '/jobs/../login', '/%6cogin', '/register', + '/analytics%0a', '/analytics?months=6\n', + ])('uses the safe fallback for the untrusted return target %j', async (from) => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + renderLoginPage('login', from); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); + }); + + it('preserves encoded internal query and fragment without decoding the navigation target', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + const from = '/analytics?months=%36&context=%C3%B8#trend'; + renderLoginPage('login', from); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(from, { replace: true })); + }); + + it('restores valid owned-workspace navigation context separately from the sign-in destination', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + const fromState = { from: '/jobs?q=Engineer', focusJobId: 42 }; + renderLoginPage('login', '/jobs/42?section=documents#files', fromState); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs/42?section=documents#files', { replace: true, state: fromState })); + }); + + it('does not replay an unsafe nested return context after authentication', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + renderLoginPage('login', '/jobs/42', { from: '/\t/evil.example', focusJobId: 42 }); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/jobs/42', { replace: true })); + }); + + it('abandons prior return context on the established account/session disposal entry', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { authenticated: true, provider: 'local' } } as any); + renderLoginPage('login', '/jobs/42', { from: '/jobs?q=Private', focusJobId: 42 }, '?sessionChanged=1'); + await userEvent.type(await screen.findByLabelText('Username or email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await click(screen.getByRole('button', { name: 'Sign in' })); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); + }); + it('opens the separate forgot-password page with the typed email prefilled', async () => { renderLoginPage(); await screen.findByLabelText('Username or email');