Files
ersatztv/web/src/UnauthorizedBanner.test.tsx
T
timothyandClaude Opus 4.8 9348a9938a feat(web): #295 PR2 SPA session-auth foundation (boot gate, CSRF header, login/setup/logout)
Replace the API-key SPA model with the session-cookie auth from PR1:

- client.ts: stop sending X-Api-Key; attach X-Csrf on mutating verbs (POST/PUT/
  PATCH/DELETE); add suppressUnauthorizedSignal to skip the global 401 signal on
  expected wrong-credentials 401s (login / change-password).
- api/auth.ts: hand-written wire types (AuthConfig/AuthSession/MachineKey — the
  auth surface is IgnoreApi, deliberately not in generated types) + endpoint fns
  (getAuthConfig/getAuthSession/login/setup/logout/changePassword/getMachineKey);
  keep the notifyUnauthorized/subscribeUnauthorized 401 signal; add
  clearLegacyStoredApiKey. Legacy get/set/clearStoredApiKey retained ONLY so the
  still-shipping ApiKeyScreen (machine-key slice) compiles without a cross-slice
  conflict — the client no longer reads them.
- AuthGate.tsx: boot gate wrapping <App/> outside the shell (mints no URL, deep
  links survive login). checking -> setup | login | ready | error, with a safe
  default AuthContext so App.test.tsx renders without a provider; config-fetch
  failure lands on an explicit error+Retry, never a blank screen.
- LoginScreen / SetupScreen: shell-less centered cards; inline 401 / 409 handling;
  SSO button + local-form gating from AuthConfig.
- UnauthorizedBanner: rewritten to prompt re-login (passive; consults the
  unsaved-changes guard before flipping the gate).
- UserMenu: TopBar sign-out (guard -> logout -> signOut), mounted next to
  ConnectMenu.
- main.tsx: wrap <AuthGate><App/></AuthGate> inside StrictMode.

Tests: client/auth/AuthGate/LoginScreen/SetupScreen/UnauthorizedBanner/UserMenu
(723 pass). Lint + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:50:24 +02:00

73 lines
2.1 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { notifyUnauthorized } from './api';
import { AuthContext, type AuthContextValue } from './AuthGate';
import { registerNavigationGuard } from './navigationGuard';
import { UnauthorizedBanner } from './UnauthorizedBanner';
function renderBanner(overrides: Partial<AuthContextValue> = {}) {
const requireLogin = overrides.requireLogin ?? vi.fn();
const value: AuthContextValue = {
username: null,
method: null,
signOut: vi.fn(),
requireLogin,
...overrides
};
render(
<AuthContext.Provider value={value}>
<UnauthorizedBanner />
</AuthContext.Provider>
);
return { requireLogin };
}
describe('UnauthorizedBanner', () => {
afterEach(() => {
cleanup();
});
it('stays hidden until a 401 signal fires', () => {
renderBanner();
expect(screen.queryByRole('alert')).toBeNull();
act(() => notifyUnauthorized());
expect(screen.getByRole('alert')).toBeTruthy();
expect(screen.getByText(/session has expired or you are signed out/)).toBeTruthy();
});
it('flips the gate to login via requireLogin when navigation is allowed', () => {
const { requireLogin } = renderBanner();
act(() => notifyUnauthorized());
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(requireLogin).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alert')).toBeNull();
});
it('respects the unsaved-changes guard — a vetoed leave keeps the banner and skips requireLogin', () => {
const { requireLogin } = renderBanner();
const unregister = registerNavigationGuard(() => false);
act(() => notifyUnauthorized());
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }));
expect(requireLogin).not.toHaveBeenCalled();
expect(screen.getByRole('alert')).toBeTruthy();
unregister();
});
it('can be dismissed', () => {
renderBanner();
act(() => notifyUnauthorized());
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(screen.queryByRole('alert')).toBeNull();
});
});