diff --git a/backend/secuscan/auth.py b/backend/secuscan/auth.py index 343f0e46..b4ef744c 100644 --- a/backend/secuscan/auth.py +++ b/backend/secuscan/auth.py @@ -5,20 +5,141 @@ Clients must supply it via: - Authorization: Bearer - X-Api-Key: + +Session management (signed cookie, no server-side state): + - POST /api/v1/auth/session — validate API key and set HttpOnly session cookie + - GET /api/v1/auth/session/check — verify active session cookie + - POST /api/v1/auth/session/logout — clear session cookie """ +import base64 +import hmac +import json import os import secrets +import time from pathlib import Path -from fastapi import Depends, HTTPException, Security, status, Request +from fastapi import Depends, HTTPException, Security, status, Request, Response from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer +from fastapi import APIRouter _bearer_scheme = HTTPBearer(auto_error=False) _api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False) _api_key: str | None = None +SESSION_TTL_SECONDS = 3600 # 1 hour +COOKIE_NAME = "secuscan_session" +_SIGNING_KEY: bytes | None = None + + +def _init_signing_key() -> bytes: + global _SIGNING_KEY + if _SIGNING_KEY is None: + _SIGNING_KEY = secrets.token_bytes(32) + return _SIGNING_KEY + + +def _make_signed_token() -> str: + key = _init_signing_key() + expires = int(time.time()) + SESSION_TTL_SECONDS + payload = json.dumps({"s": secrets.token_urlsafe(16), "e": expires}, separators=(",", ":")).encode() + payload_b64 = base64.urlsafe_b64encode(payload).decode().rstrip("=") + sig = hmac.new(key, payload, "sha256").hexdigest() + return f"{payload_b64}.{sig}" + + +def _verify_signed_token(token: str) -> bool: + key = _init_signing_key() + try: + parts = token.split(".") + if len(parts) != 2: + return False + payload_b64, sig = parts + padding = 4 - len(payload_b64) % 4 + if padding != 4: + payload_b64 += "=" * padding + payload = base64.urlsafe_b64decode(payload_b64.encode()) + expected_sig = hmac.new(key, payload, "sha256").hexdigest() + if not secrets.compare_digest(expected_sig, sig): + return False + data = json.loads(payload) + if time.time() > data["e"]: + return False + return True + except Exception: + return False + + +def _cookie_secure(request: Request) -> bool: + forwarded_proto = request.headers.get("X-Forwarded-Proto", "") + if forwarded_proto.lower() == "https": + return True + return request.url.scheme == "https" + + +auth_router = APIRouter(prefix="/api/v1/auth") + + +@auth_router.post("/session") +async def create_session(request: Request, response: Response): + """Validate the API key and set an HttpOnly session cookie. + + The client sends the API key via the X-Api-Key header (or Authorization + Bearer). On success the server sets a signed HttpOnly session cookie so + the key itself never needs to touch localStorage. The cookie is self- + contained (HMAC-signed) and requires no server-side session store. + The Secure flag is only set when the request arrives over HTTPS or + carries an X-Forwarded-Proto: https header, preserving HTTP localhost + development. + """ + if _api_key is None: + raise HTTPException( + status_code=503, detail="Authentication service not initialised" + ) + + candidate = request.headers.get("X-Api-Key") + if not candidate: + bearer = request.headers.get("Authorization", "") + if bearer.lower().startswith("bearer "): + candidate = bearer[7:] + + if not candidate or not secrets.compare_digest(candidate, _api_key): + raise HTTPException(status_code=401, detail="Invalid API key") + + token = _make_signed_token() + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + secure=_cookie_secure(request), + samesite="strict", + max_age=SESSION_TTL_SECONDS, + ) + return {"status": "authenticated"} + + +@auth_router.get("/session/check") +async def check_session(request: Request): + """Return whether the request carries a valid signed session cookie.""" + token = request.cookies.get(COOKIE_NAME) + if token and _verify_signed_token(token): + return {"authenticated": True} + return {"authenticated": False} + + +@auth_router.post("/session/logout") +async def logout_session(request: Request, response: Response): + """Destroy the session cookie.""" + response.delete_cookie(COOKIE_NAME) + return {"status": "logged_out"} + + +def is_authenticated_by_session(request: Request) -> bool: + token = request.cookies.get(COOKIE_NAME) + return bool(token and _verify_signed_token(token)) + def init_api_key(data_dir: str) -> str: """ @@ -47,17 +168,23 @@ async def require_api_key( x_api_key: str | None = Security(_api_key_header), ) -> str: """ - FastAPI dependency — rejects requests that do not carry the correct API key. + FastAPI dependency — rejects requests that do not carry the correct API key + or a valid session cookie. Accepts the key in either: - ``Authorization: Bearer `` - ``X-Api-Key: `` + - Valid ``secuscan_session`` HttpOnly cookie (set via POST /auth/session) """ if request is not None and request.url.path.startswith("/api/v1/admin"): # Admin endpoints have their own separate verify_admin_access dependency. # We bypass require_api_key verification to avoid blocking valid admin key requests. return "" + # Allow requests authenticated via session cookie + if request is not None and is_authenticated_by_session(request): + return "session-authenticated" + if _api_key is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 1cd16bd7..4001063c 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -22,7 +22,7 @@ from .request_context import get_request_id from .config import settings -from .auth import init_api_key +from .auth import init_api_key, auth_router from .cache import init_cache, cache as global_cache from .database import init_db, db as global_db from .routes import router @@ -200,6 +200,7 @@ async def custom_unhandled_exception_handler(request: Request, exc: Exception): return response # Include API routes +app.include_router(auth_router) app.include_router(router) app.include_router(saved_views_router) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f273000..6d3e4a12 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,7 +19,7 @@ import { ThemeProvider } from './components/ThemeContext' import { ToastProvider } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' -import { AUTH_REQUIRED_EVENT, getStoredApiKey } from './api' +import { AUTH_REQUIRED_EVENT, checkAuthSession } from './api' export function AppRoutes() { return ( @@ -41,8 +41,15 @@ export function AppRoutes() { } export default function App() { - // True when setup is needed: no key stored, or any request got a 401. - const [needsKey, setNeedsKey] = useState(() => !getStoredApiKey()) + const [needsKey, setNeedsKey] = useState(true) + const [checkingSession, setCheckingSession] = useState(true) + + useEffect(() => { + checkAuthSession().then((authenticated) => { + setNeedsKey(!authenticated) + setCheckingSession(false) + }) + }, []) useEffect(() => { function onAuthRequired() { @@ -52,14 +59,16 @@ export default function App() { return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) }, []) + if (checkingSession) { + return null + } + return ( {needsKey ? ( - // Render ONLY the setup screen — no page routes are mounted, so no - // API calls can fire and spam 401 failures before the key is saved. setNeedsKey(false)} /> ) : ( diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 388bdba2..c097d728 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -288,26 +288,59 @@ export interface TaskStartResponse { stream_url: string } -const API_KEY_STORAGE_KEY = 'secuscan_api_key' +let _apiKey: string | null = null export function getStoredApiKey(): string | null { + return _apiKey +} + +export function setStoredApiKey(key: string): void { + _apiKey = key +} + +export function clearStoredApiKey(): void { + _apiKey = null +} + +export async function authenticateWithApiKey(apiKey: string): Promise { + const response = await fetch(`${API_BASE}/auth/session`, { + method: 'POST', + headers: { 'X-Api-Key': apiKey }, + credentials: 'include', + }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error(body?.detail || 'Authentication failed') + } + _apiKey = apiKey +} + +export async function checkAuthSession(): Promise { try { - return localStorage.getItem(API_KEY_STORAGE_KEY) || null + const response = await fetch(`${API_BASE}/auth/session/check`, { + credentials: 'include', + }) + const data = await response.json() + return !!data.authenticated } catch { - return null + return false } } -export function setStoredApiKey(key: string): void { +export async function logoutSession(): Promise { try { - localStorage.setItem(API_KEY_STORAGE_KEY, key) + await fetch(`${API_BASE}/auth/session/logout`, { + method: 'POST', + credentials: 'include', + }) } catch { - // ignore storage errors + // ignore } + _apiKey = null } function getApiKey(): string | null { - return getStoredApiKey() + return _apiKey } /** Fired on the window when any API request receives HTTP 401. */ @@ -327,12 +360,12 @@ async function request(path: string, init?: RequestInit): Promise { ...authHeaders, ...(init?.headers as Record | undefined), }, + credentials: 'include', signal: controller.signal, }) if (response.status === 401) { - // Notify the app so it can show the API-key setup UI without every - // caller needing to handle auth independently. + _apiKey = null window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) throw new Error('AUTH_REQUIRED') } diff --git a/frontend/src/components/ApiKeySetupModal.tsx b/frontend/src/components/ApiKeySetupModal.tsx index 60ab4c10..c83e9bc8 100644 --- a/frontend/src/components/ApiKeySetupModal.tsx +++ b/frontend/src/components/ApiKeySetupModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { setStoredApiKey } from '../api' +import { authenticateWithApiKey } from '../api' interface Props { onSaved: () => void @@ -10,24 +10,29 @@ interface Props { * * Shown when the app receives HTTP 401 or detects no stored API key. * The operator reads the key from `backend/data/.api_key`, pastes it here, - * and clicks Save. The key is written only to localStorage (secuscan_api_key); - * it is never sent to any server other than as the X-Api-Key request header. + * and clicks Save. The key is sent to the backend which validates it and + * sets an HttpOnly session cookie; the raw key is never persisted in the + * browser. */ export default function ApiKeySetupModal({ onSaved }: Props) { const [key, setKey] = useState('') const [visible, setVisible] = useState(false) const [error, setError] = useState('') - function handleSave() { + async function handleSave() { const trimmed = key.trim() if (!trimmed) { setError('Please enter the API key.') return } - setStoredApiKey(trimmed) - setKey('') - setError('') - onSaved() + try { + await authenticateWithApiKey(trimmed) + setKey('') + setError('') + onSaved() + } catch (err: any) { + setError(err?.message || 'Authentication failed. Check the API key.') + } } return ( @@ -116,8 +121,8 @@ export default function ApiKeySetupModal({ onSaved }: Props) { Save and connect

- The key is stored only in your browser's localStorage and sent exclusively - as the X-Api-Key request header — it is never stored server-side. + The key is sent to the backend which validates it and sets an HttpOnly + session cookie. The raw key is never persisted in the browser.

diff --git a/frontend/src/components/ApiKeySetupScreen.tsx b/frontend/src/components/ApiKeySetupScreen.tsx index 955ead32..be23b37d 100644 --- a/frontend/src/components/ApiKeySetupScreen.tsx +++ b/frontend/src/components/ApiKeySetupScreen.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { setStoredApiKey } from '../api' +import { authenticateWithApiKey } from '../api' interface Props { onSaved: () => void @@ -13,24 +13,27 @@ interface Props { * component mounts and no protected API call fires before the key is saved. * * The operator reads the key from the server key file and pastes it here. - * The key is stored only in localStorage under `secuscan_api_key` and sent - * exclusively as the `X-Api-Key` request header — never logged or stored - * server-side. + * The key is sent to the backend which validates it and sets an HttpOnly + * session cookie; the raw key is never persisted in the browser. */ export default function ApiKeySetupScreen({ onSaved }: Props) { const [key, setKey] = useState('') const [error, setError] = useState('') - function handleSave() { + async function handleSave() { const trimmed = key.trim() if (!trimmed) { setError('Please enter the API key.') return } - setStoredApiKey(trimmed) - setKey('') - setError('') - onSaved() + try { + await authenticateWithApiKey(trimmed) + setKey('') + setError('') + onSaved() + } catch (err: any) { + setError(err?.message || 'Authentication failed. Check the API key.') + } } return ( @@ -127,10 +130,9 @@ export default function ApiKeySetupScreen({ onSaved }: Props) { Save and connect

- The key is stored only in your browser's localStorage under{' '} - secuscan_api_key and sent as the{' '} - X-Api-Key header on every API request. It is never transmitted - to any third party or stored on the server beyond the key file. + The key is sent to the backend which validates it and sets an HttpOnly + session cookie. The raw key is never persisted in the browser and is held + only in memory for the duration of the page session.

diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a0c6d56c..6f4e2a2b 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -3,12 +3,14 @@ import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from '../components/ThemeContext' import { useToast } from '../components/ToastContext' import { + authenticateWithApiKey, + clearStoredApiKey, createNotificationRule, deleteNotificationRule, getStoredApiKey, listNotificationHistory, listNotificationRules, - setStoredApiKey, + logoutSession, updateNotificationRule, type NotificationChannelType, type NotificationHistoryRow, @@ -205,20 +207,25 @@ export default function Settings() { } } - const handleSaveApiKey = () => { + const handleSaveApiKey = async () => { const trimmed = apiKey.trim() if (!trimmed) { addToast("API key cannot be empty", "error") return } - setStoredApiKey(trimmed) - addToast("API key saved — all future requests will use this key", "success") + try { + await authenticateWithApiKey(trimmed) + addToast("API key saved — all future requests will use this key", "success") + } catch (err: any) { + addToast(err?.message || "Authentication failed", "error") + } } - const handleClearApiKey = () => { + const handleClearApiKey = async () => { if (window.confirm("Clear the stored API key? The UI will return 401 errors until a valid key is configured.")) { setApiKey('') - setStoredApiKey('') + clearStoredApiKey() + await logoutSession() addToast("API key cleared", "info") } } @@ -413,7 +420,7 @@ export default function Settings() {

- Read from backend/data/.api_key after starting the backend. Stored locally in browser — never sent to any remote server. + Read from backend/data/.api_key after starting the backend. Sent to the backend which sets an HttpOnly session cookie — never persisted in browser storage.

diff --git a/frontend/testing/unit/App.auth.gate.test.tsx b/frontend/testing/unit/App.auth.gate.test.tsx index 316e8444..fe427c6b 100644 --- a/frontend/testing/unit/App.auth.gate.test.tsx +++ b/frontend/testing/unit/App.auth.gate.test.tsx @@ -1,23 +1,22 @@ /** - * App-level first-run auth gate tests (PR #278). + * App-level first-run auth gate tests. * * The core reviewer requirement: once auth is enabled, the app must NOT let * any protected API call fire before the operator has provided the key. * * Covers: - * - No key stored → setup screen is rendered; no fetch() is called. + * - No session → setup screen is rendered; no data fetch is called. * - Saving a valid key → route tree replaces the setup screen. * - Saving an empty key → validation error; still on setup screen. - * - Key already stored → app shell renders immediately; no setup screen. + * - Session already established → app shell renders immediately. * - AUTH_REQUIRED_EVENT fired → setup screen re-appears; app shell hidden. - * - New key saved after 401 → app shell returns; localStorage updated. + * - New key saved after 401 → app shell returns; key stored in memory. */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -// vi.mock calls are hoisted — all factories must be self-contained. vi.mock('react-router-dom', () => ({ BrowserRouter: ({ children }: { children: React.ReactNode }) => React.createElement('div', { 'data-testid': 'router' }, children), @@ -60,44 +59,71 @@ vi.mock('../../src/pages/Workflows', () => ({ default: () => React.createElement('div', { 'data-testid': 'page-workflows' }), })) +vi.mock('../../src/api', async (importOriginal: () => Promise>) => { + const actual = await importOriginal() + return { + ...actual, + checkAuthSession: vi.fn(), + authenticateWithApiKey: vi.fn(), + } +}) + import App from '../../src/App' -import { AUTH_REQUIRED_EVENT, setStoredApiKey } from '../../src/api' +import { AUTH_REQUIRED_EVENT, clearStoredApiKey } from '../../src/api' -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- +let mockCheckAuthSession: import('vitest').Mock<(...args: any[]) => any> +let mockAuthenticateWithApiKey: import('vitest').Mock<(...args: any[]) => any> -beforeEach(() => { +beforeEach(async () => { + clearStoredApiKey() localStorage.clear() vi.unstubAllGlobals() + const api = await import('../../src/api') + mockCheckAuthSession = api.checkAuthSession as import('vitest').Mock + mockAuthenticateWithApiKey = api.authenticateWithApiKey as import('vitest').Mock + mockCheckAuthSession.mockReset() + mockAuthenticateWithApiKey.mockReset() }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() + clearStoredApiKey() localStorage.clear() }) // --------------------------------------------------------------------------- -// First-run: no key stored +// First-run: no session // --------------------------------------------------------------------------- -describe('first-run gate (no key stored)', () => { - it('renders the setup screen instead of the app routes', () => { +describe('first-run gate (no session)', () => { + beforeEach(() => { + mockCheckAuthSession.mockResolvedValue(false) + mockAuthenticateWithApiKey.mockResolvedValue(undefined) + }) + + it('renders the setup screen instead of the app routes', async () => { const { container } = render(React.createElement(App)) - expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() + await waitFor(() => + expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() + ) expect(container.querySelector('[data-testid="app-shell"]')).toBeNull() }) - it('does not call fetch() while the setup screen is showing', () => { + it('does not call fetch() while the setup screen is showing', async () => { const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) render(React.createElement(App)) + await waitFor(() => + expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() + ) expect(fetchSpy).not.toHaveBeenCalled() }) it('shows the app shell after the operator saves a valid key', async () => { render(React.createElement(App)) + await waitFor(() => screen.getByLabelText(/Backend API Key/i)) + fireEvent.change(screen.getByLabelText(/Backend API Key/i), { target: { value: 'my-operator-key' }, }) @@ -109,20 +135,25 @@ describe('first-run gate (no key stored)', () => { expect(screen.getByTestId('app-shell')).toBeTruthy() }) - it('persists the key to localStorage after save', async () => { + it('does not write the key to localStorage after save', async () => { render(React.createElement(App)) + await waitFor(() => screen.getByLabelText(/Backend API Key/i)) + fireEvent.change(screen.getByLabelText(/Backend API Key/i), { target: { value: 'stored-key-abc' }, }) fireEvent.click(screen.getByText(/Save and connect/i)) await waitFor(() => - expect(localStorage.getItem('secuscan_api_key')).toBe('stored-key-abc') + expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() ) + expect(localStorage.getItem('secuscan_api_key')).toBeNull() }) - it('shows a validation error and stays on setup screen for empty key', () => { + it('shows a validation error and stays on setup screen for empty key', async () => { render(React.createElement(App)) + await waitFor(() => screen.getByLabelText(/Backend API Key/i)) + fireEvent.click(screen.getByText(/Save and connect/i)) expect(screen.getByRole('alert')).toBeTruthy() expect(screen.getByRole('main', { name: /api key setup/i })).toBeTruthy() @@ -130,6 +161,8 @@ describe('first-run gate (no key stored)', () => { it('saves key on Enter keypress in the input', async () => { render(React.createElement(App)) + await waitFor(() => screen.getByLabelText(/Backend API Key/i)) + const input = screen.getByLabelText(/Backend API Key/i) fireEvent.change(input, { target: { value: 'enter-key-test' } }) fireEvent.keyDown(input, { key: 'Enter' }) @@ -137,25 +170,22 @@ describe('first-run gate (no key stored)', () => { await waitFor(() => expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() ) - expect(localStorage.getItem('secuscan_api_key')).toBe('enter-key-test') + expect(mockAuthenticateWithApiKey).toHaveBeenCalledWith('enter-key-test') }) }) // --------------------------------------------------------------------------- -// Key already stored: app renders normally +// Session already established: app renders normally // --------------------------------------------------------------------------- -describe('key already stored', () => { - it('renders the app shell without the setup screen', () => { - setStoredApiKey('pre-seeded-key') - render(React.createElement(App)) - expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() - expect(screen.getByTestId('app-shell')).toBeTruthy() +describe('session already established', () => { + beforeEach(() => { + mockCheckAuthSession.mockResolvedValue(true) }) - it('does not render the setup screen when a whitespace-trimmed key exists', () => { - localStorage.setItem('secuscan_api_key', 'some-valid-key') + it('renders the app shell without the setup screen', async () => { render(React.createElement(App)) + await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() }) }) @@ -165,10 +195,14 @@ describe('key already stored', () => { // --------------------------------------------------------------------------- describe('401 re-triggers setup screen', () => { + beforeEach(() => { + mockCheckAuthSession.mockResolvedValue(true) + mockAuthenticateWithApiKey.mockResolvedValue(undefined) + }) + it('shows the setup screen when AUTH_REQUIRED_EVENT fires', async () => { - setStoredApiKey('valid-key') render(React.createElement(App)) - expect(screen.getByTestId('app-shell')).toBeTruthy() + await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) @@ -179,8 +213,9 @@ describe('401 re-triggers setup screen', () => { }) it('hides the setup screen after a new key is saved post-401', async () => { - setStoredApiKey('valid-key') render(React.createElement(App)) + await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) + act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) await waitFor(() => screen.getByRole('main', { name: /api key setup/i })) @@ -193,12 +228,12 @@ describe('401 re-triggers setup screen', () => { expect(screen.queryByRole('main', { name: /api key setup/i })).toBeNull() ) expect(screen.getByTestId('app-shell')).toBeTruthy() - expect(localStorage.getItem('secuscan_api_key')).toBe('new-key-after-401') + expect(mockAuthenticateWithApiKey).toHaveBeenCalledWith('new-key-after-401') }) it('does not call fetch() after 401 until the new key is saved', async () => { - setStoredApiKey('old-key') render(React.createElement(App)) + await waitFor(() => expect(screen.getByTestId('app-shell')).toBeTruthy()) const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) @@ -206,7 +241,6 @@ describe('401 re-triggers setup screen', () => { act(() => { window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) }) await waitFor(() => screen.getByRole('main', { name: /api key setup/i })) - // The app shell is gone — no components that would call fetch are mounted. expect(fetchSpy).not.toHaveBeenCalled() }) }) diff --git a/frontend/testing/unit/api.auth.test.ts b/frontend/testing/unit/api.auth.test.ts index dc96372f..ea261d5b 100644 --- a/frontend/testing/unit/api.auth.test.ts +++ b/frontend/testing/unit/api.auth.test.ts @@ -1,9 +1,9 @@ /** - * Frontend auth tests for PR #278. + * Frontend auth tests. * * Covers: - * - getStoredApiKey returns null when localStorage is empty - * - setStoredApiKey persists the key to localStorage + * - getStoredApiKey returns null when no key is stored + * - setStoredApiKey stores the key in memory (not localStorage) * - request() includes X-Api-Key header when a key is stored * - request() omits X-Api-Key when no key is stored * - request() fires AUTH_REQUIRED_EVENT on HTTP 401 @@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AUTH_REQUIRED_EVENT, + clearStoredApiKey, getStoredApiKey, setStoredApiKey, listPlugins, @@ -39,9 +40,14 @@ function mockResponse(status: number, body: unknown = {}) { describe('getStoredApiKey / setStoredApiKey', () => { beforeEach(() => { + clearStoredApiKey() localStorage.clear() }) + afterEach(() => { + clearStoredApiKey() + }) + it('returns null when no key is stored', () => { expect(getStoredApiKey()).toBeNull() }) @@ -57,11 +63,9 @@ describe('getStoredApiKey / setStoredApiKey', () => { expect(getStoredApiKey()).toBe('new-key') }) - it('stores the key only under secuscan_api_key', () => { + it('does not write the key to localStorage', () => { setStoredApiKey('abc123') - expect(localStorage.getItem('secuscan_api_key')).toBe('abc123') - // No other localStorage entry should be written by setStoredApiKey. - expect(localStorage.length).toBe(1) + expect(localStorage.getItem('secuscan_api_key')).toBeNull() }) }) @@ -72,6 +76,7 @@ describe('getStoredApiKey / setStoredApiKey', () => { describe('request() X-Api-Key header', () => { afterEach(() => { vi.unstubAllGlobals() + clearStoredApiKey() localStorage.clear() }) @@ -107,6 +112,7 @@ describe('request() X-Api-Key header', () => { describe('request() 401 handling', () => { afterEach(() => { vi.unstubAllGlobals() + clearStoredApiKey() localStorage.clear() }) @@ -203,6 +209,7 @@ vi.spyOn(window, 'setTimeout').mockImplementation(() => timeoutId) describe('request() successful authenticated request', () => { afterEach(() => { vi.unstubAllGlobals() + clearStoredApiKey() localStorage.clear() }) @@ -224,6 +231,7 @@ describe('API key is never logged', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() + clearStoredApiKey() localStorage.clear() })