From d98e3ab7a1649593ef5ef843022c1414aedbf00d Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 31 May 2026 12:04:21 +0200 Subject: [PATCH] =?UTF-8?q?test(phase-02):=20add=20Nyquist=20validation=20?= =?UTF-8?q?tests=20=E2=80=94=20fill=20SEC-05,=20AUTH-08,=20SEC-03=20and=20?= =?UTF-8?q?frontend=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 test files, 60 new tests (14 backend + 46 frontend). All green. Co-Authored-By: Claude Sonnet 4.6 --- backend/tests/test_constant_time_auth.py | 188 ++++++++++++++ backend/tests/test_security_headers.py | 182 +++++++++++++ backend/tests/test_totp_replay.py | 239 ++++++++++++++++++ .../admin/__tests__/AdminAiConfigTab.test.js | 139 ++++++++++ .../admin/__tests__/AdminQuotasTab.test.js | 147 +++++++++++ .../admin/__tests__/AdminUsersTab.test.js | 146 +++++++++++ .../__tests__/PasswordStrengthBar.test.js | 117 +++++++++ frontend/src/stores/__tests__/auth.test.js | 223 ++++++++++++++++ 8 files changed, 1381 insertions(+) create mode 100644 backend/tests/test_constant_time_auth.py create mode 100644 backend/tests/test_security_headers.py create mode 100644 backend/tests/test_totp_replay.py create mode 100644 frontend/src/components/admin/__tests__/AdminAiConfigTab.test.js create mode 100644 frontend/src/components/admin/__tests__/AdminQuotasTab.test.js create mode 100644 frontend/src/components/admin/__tests__/AdminUsersTab.test.js create mode 100644 frontend/src/components/auth/__tests__/PasswordStrengthBar.test.js create mode 100644 frontend/src/stores/__tests__/auth.test.js diff --git a/backend/tests/test_constant_time_auth.py b/backend/tests/test_constant_time_auth.py new file mode 100644 index 0000000..4bb44f1 --- /dev/null +++ b/backend/tests/test_constant_time_auth.py @@ -0,0 +1,188 @@ +""" +SEC-03: Constant-time comparison for token/code verification. + +Two layers of verification: + 1. Source-code inspection: confirm hmac.compare_digest is used in auth service. + 2. Behavioral tests: confirm that wrong tokens/codes are rejected correctly + (proving the comparison pathway functions at minimum). + +The behavioral tests do not prove timing properties (impossible without +statistical measurement), but they prove the reject-path works — a necessary +condition for constant-time implementations to be meaningful. +""" +from __future__ import annotations + +import inspect +import os +import re + +import pytest + + +# ── Source inspection ───────────────────────────────────────────────────────── + +def test_hmac_compare_digest_used_in_hibp_check(): + """SEC-03: auth service uses hmac.compare_digest for HIBP suffix comparison. + + The HIBP check compares SHA-1 suffixes; using == would leak timing information + about whether the prefix matches. hmac.compare_digest is the required approach. + """ + auth_path = os.path.join( + os.path.dirname(__file__), "..", "services", "auth.py" + ) + with open(auth_path) as f: + source = f.read() + + assert "hmac.compare_digest" in source, ( + "SEC-03 VIOLATED: 'hmac.compare_digest' not found in services/auth.py. " + "All security-sensitive comparisons must use constant-time comparison." + ) + + +def test_no_plain_equality_for_suffix_comparison(): + """SEC-03: The HIBP check must not use plain == to compare hash suffixes. + + A plain == comparison leaks timing information — rejected by SEC-03. + The implementation must use hmac.compare_digest. + """ + auth_path = os.path.join( + os.path.dirname(__file__), "..", "services", "auth.py" + ) + with open(auth_path) as f: + source = f.read() + + # Detect patterns like: candidate_suffix == suffix or suffix == candidate_suffix + # These would be in the HIBP check or token comparison area + # We allow == for simple type/key checks but not for suffix/token/code comparisons + # Look specifically for suffix comparison using == + dangerous_patterns = re.findall( + r'candidate_suffix\s*==\s*\w+|suffix\s*==\s*candidate_suffix', + source + ) + assert len(dangerous_patterns) == 0, ( + f"SEC-03 VIOLATED: Plain equality (==) used for suffix comparison: " + f"{dangerous_patterns}. Must use hmac.compare_digest." + ) + + +# ── Behavioral: verify_password ─────────────────────────────────────────────── + +def test_verify_password_correct_password_returns_true_constant_time(): + """SEC-03: verify_password returns True for correct password.""" + from services.auth import hash_password, verify_password + + h = hash_password("Correct1Password!") + result = verify_password("Correct1Password!", h) + assert result is True, ( + "verify_password should return True for the correct password" + ) + + +def test_verify_password_wrong_password_returns_false_constant_time(): + """SEC-03: verify_password returns False for incorrect password. + + This behavioral test confirms the rejection path works. The underlying + pwdlib uses constant-time Argon2 verification. + """ + from services.auth import hash_password, verify_password + + h = hash_password("Correct1Password!") + result = verify_password("WrongPassword1!", h) + assert result is False, ( + "SEC-03: verify_password must return False for wrong password — " + "the comparison pathway is broken if this fails." + ) + + +def test_verify_password_empty_wrong_returns_false(): + """SEC-03: verify_password returns False for empty string against real hash.""" + from services.auth import hash_password, verify_password + + h = hash_password("ActualPassword1!") + result = verify_password("", h) + assert result is False + + +def test_verify_password_against_invalid_hash_returns_false(): + """SEC-03: verify_password returns False (not exception) for a malformed hash.""" + from services.auth import verify_password + + # verify_password must never raise — it catches exceptions and returns False + result = verify_password("anything", "not-a-valid-argon2-hash") + assert result is False, ( + "verify_password must return False on exception, not propagate it" + ) + + +# ── Behavioral: verify_backup_code ─────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_backup_code_verification_is_constant_time(db_session): + """SEC-03: verify_backup_code checks ALL rows (no early exit on match). + + The implementation must iterate every unused backup code even after finding + a match, to prevent timing-based enumeration of which position matched. + This test checks the behavioral correctness of the implementation. + """ + import uuid as _uuid + from db.models import User, Quota + from services.auth import ( + generate_backup_codes, + hash_password, + store_backup_codes, + verify_backup_code, + ) + from sqlalchemy import select + from db.models import BackupCode + + user_id = _uuid.uuid4() + user = User( + id=user_id, + handle=f"ct_user_{user_id.hex[:6]}", + email=f"ct_{user_id.hex[:6]}@example.com", + password_hash=hash_password("ValidP@ss1!"), + role="user", + is_active=True, + password_must_change=False, + ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) + db_session.add(user) + db_session.add(quota) + await db_session.commit() + + codes = generate_backup_codes(10) + await store_backup_codes(db_session, user_id, codes) + + # Verify: correct code returns True + result = await verify_backup_code(db_session, user_id, codes[0]) + assert result is True, "verify_backup_code must return True for a valid backup code" + + # Verify: SAME code second time returns False (marked used) + result2 = await verify_backup_code(db_session, user_id, codes[0]) + assert result2 is False, ( + "verify_backup_code must return False when the code has already been used" + ) + + # Verify: wrong code returns False + result3 = await verify_backup_code(db_session, user_id, "XXXXXXXX") + assert result3 is False, ( + "verify_backup_code must return False for a code that was never issued" + ) + + # Verify: after first code is consumed, remaining codes still work (independent rows) + result4 = await verify_backup_code(db_session, user_id, codes[1]) + assert result4 is True, ( + "verify_backup_code must still validate unused backup codes after another was consumed" + ) + + # Confirm the implementation inspects source — iterates all rows + auth_path = os.path.join( + os.path.dirname(__file__), "..", "services", "auth.py" + ) + with open(auth_path) as f: + source = f.read() + + assert "matched_row" in source and "keep iterating" in source, ( + "SEC-03: verify_backup_code should iterate all rows even after matching " + "(constant-time invariant). Implementation comment 'keep iterating' not found." + ) diff --git a/backend/tests/test_security_headers.py b/backend/tests/test_security_headers.py new file mode 100644 index 0000000..001ec9f --- /dev/null +++ b/backend/tests/test_security_headers.py @@ -0,0 +1,182 @@ +""" +SEC-05: Every API response must include the mandatory security headers. + +Tests verify that SecurityHeadersMiddleware injects: + - Content-Security-Policy + - X-Frame-Options: DENY + - X-Content-Type-Options: nosniff + +These headers must be present on every response — GET and POST alike, +authenticated and unauthenticated. +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + + +# ── FakeRedis (needed by login endpoint) ───────────────────────────────────── + +class _FakeRedis: + """Minimal in-process Redis stand-in for login rate-limit checks.""" + + def __init__(self): + self._store: dict = {} + + async def get(self, key): + entry = self._store.get(key) + if entry is None: + return None + val, exp = entry + if exp is not None and datetime.now(timezone.utc).timestamp() > exp: + del self._store[key] + return None + return val + + async def incr(self, key): + entry = self._store.get(key) + if entry is None: + self._store[key] = (1, None) + return 1 + val, exp = entry + new_val = val + 1 + self._store[key] = (new_val, exp) + return new_val + + async def expire(self, key, seconds): + if key in self._store: + val, _ = self._store[key] + self._store[key] = (val, datetime.now(timezone.utc).timestamp() + seconds) + + async def set(self, key, value, ex=None): + deadline = None + if ex is not None: + deadline = datetime.now(timezone.utc).timestamp() + ex + self._store[key] = (value, deadline) + + async def close(self): + pass + + +@pytest_asyncio.fixture +async def headers_client(db_session: AsyncSession): + """Async test client with DB override AND FakeRedis on app.state.redis.""" + from deps.db import get_db + from main import app + from api.auth import limiter as auth_limiter + + app.dependency_overrides[get_db] = lambda: db_session + app.state.redis = _FakeRedis() + + try: + auth_limiter._storage.reset() + except Exception: + pass + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +REQUIRED_HEADERS = { + "content-security-policy": None, # any value — presence is the invariant + "x-frame-options": "DENY", + "x-content-type-options": "nosniff", +} + + +def _assert_security_headers(response) -> None: + """Assert all required security headers are present with correct values.""" + for header, expected_value in REQUIRED_HEADERS.items(): + actual = response.headers.get(header) + assert actual is not None, ( + f"Missing security header '{header}' — SEC-05 requires it on every response. " + f"Status: {response.status_code}, URL: {response.url}" + ) + if expected_value is not None: + assert actual == expected_value, ( + f"Security header '{header}' has wrong value: " + f"expected '{expected_value}', got '{actual}'" + ) + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_security_headers_on_unauthenticated_get(async_client): + """GET /api/auth/me (unauthenticated) response carries all three SEC-05 headers.""" + resp = await async_client.get("/api/auth/me") + # Endpoint should 401 — we only care about headers, not status code + assert resp.status_code in (401, 403), ( + f"Expected auth error, got {resp.status_code}" + ) + _assert_security_headers(resp) + + +@pytest.mark.asyncio +async def test_security_headers_on_post_endpoint(headers_client): + """POST /api/auth/login response carries all three SEC-05 headers.""" + resp = await headers_client.post( + "/api/auth/login", + json={"email": "nobody@example.com", "password": "wrongpassword"}, + ) + # Should 401 or 429 — we care about the security headers, not auth outcome + _assert_security_headers(resp) + + +@pytest.mark.asyncio +async def test_csp_header_contains_default_src(async_client): + """CSP header must include 'default-src' directive.""" + resp = await async_client.get("/api/auth/me") + csp = resp.headers.get("content-security-policy", "") + assert "default-src" in csp, ( + f"CSP header is present but missing 'default-src' directive. Got: '{csp}'" + ) + + +@pytest.mark.asyncio +async def test_x_frame_options_is_deny(async_client): + """X-Frame-Options must be exactly 'DENY', not SAMEORIGIN or any other value.""" + resp = await async_client.get("/api/auth/me") + val = resp.headers.get("x-frame-options", "") + assert val == "DENY", ( + f"X-Frame-Options must be 'DENY' for SEC-05, got '{val}'" + ) + + +@pytest.mark.asyncio +async def test_security_headers_on_authenticated_route(headers_client, db_session): + """GET /api/auth/me with valid token also returns the three SEC-05 headers.""" + import uuid as _uuid + from db.models import User, Quota + from services.auth import hash_password, create_access_token + + user_id = _uuid.uuid4() + user = User( + id=user_id, + handle=f"hdr_user_{user_id.hex[:6]}", + email=f"hdr_{user_id.hex[:6]}@example.com", + password_hash=hash_password("StrongPass12!"), + role="user", + is_active=True, + password_must_change=False, + ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) + db_session.add(user) + db_session.add(quota) + await db_session.commit() + + token = create_access_token(str(user_id), "user") + resp = await headers_client.get( + "/api/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + _assert_security_headers(resp) diff --git a/backend/tests/test_totp_replay.py b/backend/tests/test_totp_replay.py new file mode 100644 index 0000000..30d3f18 --- /dev/null +++ b/backend/tests/test_totp_replay.py @@ -0,0 +1,239 @@ +""" +AUTH-08: TOTP replay prevention — same valid code used twice within 90 seconds +must be rejected on the second use. + +This is a distinct behavioral gap from the rate-limit test (11 calls → 429). +The replay test: one valid code → second use of the *same* code → rejected. +Uses the FakeRedis from test_auth_totp.py pattern to keep tests hermetic. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +import pytest_asyncio +import pyotp +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import User + + +# ── FakeRedis (mirrors test_auth_totp.py) ──────────────────────────────────── + +class FakeRedis: + """In-process fake Redis: stores keys with optional TTL expiry.""" + + def __init__(self): + self._store: dict = {} + + async def get(self, key): + entry = self._store.get(key) + if entry is None: + return None + val, exp = entry + if exp is not None and datetime.now(timezone.utc).timestamp() > exp: + del self._store[key] + return None + return val + + async def incr(self, key): + entry = self._store.get(key) + if entry is None: + self._store[key] = (1, None) + return 1 + val, exp = entry + new_val = val + 1 + self._store[key] = (new_val, exp) + return new_val + + async def expire(self, key, seconds): + if key in self._store: + val, _ = self._store[key] + deadline = datetime.now(timezone.utc).timestamp() + seconds + self._store[key] = (val, deadline) + + async def set(self, key, value, ex=None): + deadline = None + if ex is not None: + deadline = datetime.now(timezone.utc).timestamp() + ex + self._store[key] = (value, deadline) + + async def close(self): + pass + + +VALID_PASSWORD = "StrongPass12!" + + +@pytest_asyncio.fixture +async def totp_replay_client(db_session: AsyncSession): + """Async test client with DB override and fresh FakeRedis.""" + from deps.db import get_db + from main import app + from api.auth import limiter as auth_limiter + + app.dependency_overrides[get_db] = lambda: db_session + fake_redis = FakeRedis() + app.state.redis = fake_redis + + try: + auth_limiter._storage.reset() + except Exception: + pass + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + + +async def _setup_totp_user(client, db_session, handle="replayuser", email="replay@example.com"): + """Register a user, provision TOTP, and return (access_token, totp_secret, fake_redis). + + Directly manipulates the DB to set totp_enabled=True without consuming any + TOTP code via the API — this leaves the Redis replay-prevention store clean, + so the first login attempt with a fresh code has never been marked used. + """ + from sqlalchemy import select as _select + from services.auth import hash_password as _hash + from db.models import User, Quota + + import uuid as _uuid + import pyotp as _pyotp + + # Create user directly in the DB (avoids HIBP check in test environment) + user_id = _uuid.uuid4() + secret = _pyotp.random_base32() + user = User( + id=user_id, + handle=handle, + email=email, + password_hash=_hash(VALID_PASSWORD), + role="user", + is_active=True, + password_must_change=False, + totp_enabled=True, + totp_secret=secret, + ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) + db_session.add(user) + db_session.add(quota) + await db_session.commit() + + # Obtain an access token for this user without going through login + from services.auth import create_access_token as _create_token + token = _create_token(str(user_id), "user") + + return token, secret + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_totp_replay_rejected_same_code(totp_replay_client, db_session): + """AUTH-08: Same TOTP code used twice must be rejected on second use. + + First login with TOTP succeeds. The second login with the *identical* + code within the same 90-second window must return 401 (code already used). + """ + client = totp_replay_client + _token, secret = await _setup_totp_user(client, db_session) + + # First login step: password only — get requires_totp challenge + r1 = await client.post( + "/api/auth/login", + json={"email": "replay@example.com", "password": VALID_PASSWORD}, + ) + assert r1.status_code == 200, f"First login step failed: {r1.text}" + # Should ask for TOTP + assert r1.json().get("requires_totp") is True, ( + f"Expected TOTP challenge, got: {r1.json()}" + ) + + # Get a valid TOTP code + totp = pyotp.TOTP(secret) + valid_code = totp.now() + + # First use of the code: must succeed and return access_token + r2 = await client.post( + "/api/auth/login", + json={ + "email": "replay@example.com", + "password": VALID_PASSWORD, + "totp_code": valid_code, + }, + ) + assert r2.status_code == 200, f"First TOTP use failed: {r2.text}" + assert "access_token" in r2.json(), ( + f"Expected access_token on first TOTP use, got: {r2.json()}" + ) + + # Second use of the SAME code: must be rejected (Redis replay prevention) + r3 = await client.post( + "/api/auth/login", + json={ + "email": "replay@example.com", + "password": VALID_PASSWORD, + "totp_code": valid_code, # identical code — replay attempt + }, + ) + assert r3.status_code == 401, ( + f"AUTH-08 VIOLATED: Second use of same TOTP code within 90s window was accepted " + f"(status {r3.status_code}). Redis replay prevention is not working. " + f"Response: {r3.text}" + ) + + +@pytest.mark.asyncio +async def test_totp_replay_service_layer(db_session): + """AUTH-08: verify_totp() service returns False when same code presented twice. + + Tests the service layer directly (not via HTTP) to isolate Redis-based + replay prevention from the login rate limiter and other middleware. + """ + import uuid as _uuid + from db.models import User, Quota + from services.auth import hash_password, provision_totp, verify_totp + + # Create a user + user_id = _uuid.uuid4() + user = User( + id=user_id, + handle=f"srv_replay_{user_id.hex[:6]}", + email=f"srv_replay_{user_id.hex[:6]}@example.com", + password_hash=hash_password(VALID_PASSWORD), + role="user", + is_active=True, + password_must_change=False, + ) + quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0) + db_session.add(user) + db_session.add(quota) + await db_session.commit() + + # Provision a TOTP secret + secret, _ = await provision_totp(db_session, user_id) + + # Fresh FakeRedis for this service-layer test + fake_redis = FakeRedis() + + # Generate a valid code + totp = pyotp.TOTP(secret) + code = totp.now() + + # First verification must succeed + result1 = await verify_totp(db_session, user_id, code, fake_redis) + assert result1 is True, ( + f"First TOTP verification should succeed, got False. " + f"Code: {code}, Secret: {secret}" + ) + + # Second verification with same code must fail (replay prevention) + result2 = await verify_totp(db_session, user_id, code, fake_redis) + assert result2 is False, ( + f"AUTH-08 VIOLATED: verify_totp() accepted the same code a second time. " + f"Redis key 'totp_used:{user_id}:{code}' should block the second use." + ) diff --git a/frontend/src/components/admin/__tests__/AdminAiConfigTab.test.js b/frontend/src/components/admin/__tests__/AdminAiConfigTab.test.js new file mode 100644 index 0000000..8c0d0af --- /dev/null +++ b/frontend/src/components/admin/__tests__/AdminAiConfigTab.test.js @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Mock api/client.js +vi.mock('../../../api/client.js', () => ({ + adminListUsers: vi.fn(), + adminUpdateAiConfig: vi.fn(), +})) + +import AdminAiConfigTab from '../AdminAiConfigTab.vue' +import * as api from '../../../api/client.js' + +function makeUser(overrides = {}) { + return { + id: overrides.id ?? 'user-1', + email: overrides.email ?? 'alice@example.com', + handle: overrides.handle ?? 'alice', + role: 'user', + is_active: true, + ai_provider: overrides.ai_provider ?? null, + ai_model: overrides.ai_model ?? null, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ── onMounted: calls adminListUsers() ───────────────────────────────────────── + +describe('AdminAiConfigTab — onMounted', () => { + it('calls adminListUsers() on mount', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + mount(AdminAiConfigTab) + await flushPromises() + expect(api.adminListUsers).toHaveBeenCalledTimes(1) + }) + + it('shows "No users yet" empty state when no users', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + const w = mount(AdminAiConfigTab) + await flushPromises() + expect(w.text()).toContain('No users yet') + }) + + it('renders a row per user', async () => { + api.adminListUsers.mockResolvedValue({ + items: [ + makeUser({ id: 'u1', email: 'alice@example.com' }), + makeUser({ id: 'u2', email: 'bob@example.com' }), + ], + }) + const w = mount(AdminAiConfigTab) + await flushPromises() + expect(w.text()).toContain('alice@example.com') + expect(w.text()).toContain('bob@example.com') + }) + + it('pre-populates existing ai_provider and ai_model in inputs', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser({ id: 'u1', ai_provider: 'openai', ai_model: 'gpt-4o' })], + }) + const w = mount(AdminAiConfigTab) + await flushPromises() + + // The select should have openai selected + const select = w.find('select') + expect(select.element.value).toBe('openai') + + // The model input should have gpt-4o + const modelInput = w.find('input[type="text"]') + expect(modelInput.element.value).toBe('gpt-4o') + }) +}) + +// ── saveConfig: calls adminUpdateAiConfig(id, provider, model) ──────────────── + +describe('AdminAiConfigTab — saveConfig', () => { + it('calls adminUpdateAiConfig with user id, provider, and model on Save click', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser({ id: 'u1', email: 'alice@example.com', ai_provider: '', ai_model: '' })], + }) + api.adminUpdateAiConfig.mockResolvedValue({}) + + const w = mount(AdminAiConfigTab) + await flushPromises() + + // Select a provider + const select = w.find('select') + await select.setValue('anthropic') + + // Enter a model + const modelInput = w.find('input[type="text"]') + await modelInput.setValue('claude-3-5-sonnet') + + // Click Save + const saveBtn = w.findAll('button').find(b => b.text().includes('Save')) + expect(saveBtn).toBeTruthy() + await saveBtn.trigger('click') + await flushPromises() + + expect(api.adminUpdateAiConfig).toHaveBeenCalledWith('u1', 'anthropic', 'claude-3-5-sonnet') + }) + + it('shows "Saved" confirmation text after successful save', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser({ id: 'u1', email: 'alice@example.com' })], + }) + api.adminUpdateAiConfig.mockResolvedValue({}) + + const w = mount(AdminAiConfigTab) + await flushPromises() + + const saveBtn = w.findAll('button').find(b => b.text().includes('Save')) + await saveBtn.trigger('click') + await flushPromises() + + expect(w.text()).toContain('Saved') + }) + + it('passes null for empty provider string to API', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser({ id: 'u1', ai_provider: null, ai_model: null })], + }) + api.adminUpdateAiConfig.mockResolvedValue({}) + + const w = mount(AdminAiConfigTab) + await flushPromises() + + // Select is empty string '' — saveConfig converts '' to null + const saveBtn = w.findAll('button').find(b => b.text().includes('Save')) + await saveBtn.trigger('click') + await flushPromises() + + // Called with null for both empty provider and model + expect(api.adminUpdateAiConfig).toHaveBeenCalledWith('u1', null, null) + }) +}) diff --git a/frontend/src/components/admin/__tests__/AdminQuotasTab.test.js b/frontend/src/components/admin/__tests__/AdminQuotasTab.test.js new file mode 100644 index 0000000..ed57863 --- /dev/null +++ b/frontend/src/components/admin/__tests__/AdminQuotasTab.test.js @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Mock api/client.js +vi.mock('../../../api/client.js', () => ({ + adminListUsers: vi.fn(), + adminGetUserQuota: vi.fn(), + adminUpdateQuota: vi.fn(), +})) + +import AdminQuotasTab from '../AdminQuotasTab.vue' +import * as api from '../../../api/client.js' + +const MB = 1048576 + +function makeUser(id, email) { + return { id, email, handle: email.split('@')[0], role: 'user', is_active: true } +} + +function makeQuota(userId, usedMB, limitMB) { + return { + user_id: userId, + used_bytes: usedMB * MB, + limit_bytes: limitMB * MB, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ── onMounted: loads users + quotas ────────────────────────────────────────── + +describe('AdminQuotasTab — onMounted', () => { + it('calls adminListUsers() on mount', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + mount(AdminQuotasTab) + await flushPromises() + expect(api.adminListUsers).toHaveBeenCalledTimes(1) + }) + + it('shows "No users yet" empty state when API returns empty list', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + const w = mount(AdminQuotasTab) + await flushPromises() + expect(w.text()).toContain('No users yet') + }) + + it('calls adminGetUserQuota for each user', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser('u1', 'alice@example.com'), makeUser('u2', 'bob@example.com')], + }) + api.adminGetUserQuota + .mockResolvedValueOnce(makeQuota('u1', 10, 100)) + .mockResolvedValueOnce(makeQuota('u2', 50, 200)) + + mount(AdminQuotasTab) + await flushPromises() + + expect(api.adminGetUserQuota).toHaveBeenCalledWith('u1') + expect(api.adminGetUserQuota).toHaveBeenCalledWith('u2') + }) + + it('displays quota data in the table', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser('u1', 'alice@example.com')], + }) + api.adminGetUserQuota.mockResolvedValue(makeQuota('u1', 10, 100)) + + const w = mount(AdminQuotasTab) + await flushPromises() + + expect(w.text()).toContain('alice@example.com') + // 10 MB used, 100 MB limit + expect(w.text()).toContain('10 MB') + expect(w.text()).toContain('100 MB') + }) +}) + +// ── saveQuota: calls adminUpdateQuota(id, bytes) ────────────────────────────── + +describe('AdminQuotasTab — saveQuota', () => { + it('calls adminUpdateQuota with user id and new limit in bytes on save', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser('u1', 'alice@example.com')], + }) + api.adminGetUserQuota.mockResolvedValue(makeQuota('u1', 10, 100)) + api.adminUpdateQuota.mockResolvedValue({ + user_id: 'u1', + used_bytes: 10 * MB, + limit_bytes: 200 * MB, + warning: false, + }) + + const w = mount(AdminQuotasTab) + await flushPromises() + + // Click "Edit" button + const editBtn = w.find('button') + expect(editBtn.text()).toContain('Edit') + await editBtn.trigger('click') + await w.vm.$nextTick() + + // Change the limit input to 200 MB + const input = w.find('input[type="number"]') + await input.setValue(200) + + // Click "Save" + const saveBtn = w.findAll('button').find(b => b.text().includes('Save')) + await saveBtn.trigger('click') + await flushPromises() + + expect(api.adminUpdateQuota).toHaveBeenCalledWith('u1', 200 * MB) + }) + + it('shows warning text when API response has warning: true', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser('u1', 'alice@example.com')], + }) + api.adminGetUserQuota.mockResolvedValue(makeQuota('u1', 90, 100)) + api.adminUpdateQuota.mockResolvedValue({ + user_id: 'u1', + used_bytes: 90 * MB, + limit_bytes: 50 * MB, // below current usage + warning: true, + }) + + const w = mount(AdminQuotasTab) + await flushPromises() + + // Enter edit mode + await w.find('button').trigger('click') + await w.vm.$nextTick() + + // Set limit below current usage + const input = w.find('input[type="number"]') + await input.setValue(50) + + // Save + const saveBtn = w.findAll('button').find(b => b.text().includes('Save')) + await saveBtn.trigger('click') + await flushPromises() + + // Warning text must appear + expect(w.text()).toMatch(/below current usage|uploads will be blocked/i) + }) +}) diff --git a/frontend/src/components/admin/__tests__/AdminUsersTab.test.js b/frontend/src/components/admin/__tests__/AdminUsersTab.test.js new file mode 100644 index 0000000..cfa67e9 --- /dev/null +++ b/frontend/src/components/admin/__tests__/AdminUsersTab.test.js @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// Mock api/client.js +vi.mock('../../../api/client.js', () => ({ + adminListUsers: vi.fn(), + adminDeactivateUser: vi.fn(), + adminReactivateUser: vi.fn(), + adminDeleteUser: vi.fn(), + adminResetUserPassword: vi.fn(), + adminCreateUser: vi.fn(), +})) + +import AdminUsersTab from '../AdminUsersTab.vue' +import * as api from '../../../api/client.js' + +function makeUser(overrides = {}) { + return { + id: overrides.id ?? 'user-1', + email: overrides.email ?? 'alice@example.com', + handle: overrides.handle ?? 'alice', + role: overrides.role ?? 'user', + is_active: overrides.is_active ?? true, + totp_enabled: false, + created_at: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +// ── onMounted: calls adminListUsers() ───────────────────────────────────────── + +describe('AdminUsersTab — onMounted', () => { + it('calls adminListUsers() on mount', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + mount(AdminUsersTab) + await flushPromises() + expect(api.adminListUsers).toHaveBeenCalledTimes(1) + }) + + it('populates user list from API response', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser({ id: 'u1', email: 'alice@example.com' })], + }) + const w = mount(AdminUsersTab) + await flushPromises() + expect(w.text()).toContain('alice@example.com') + }) + + it('shows "No users yet" empty state when API returns empty list', async () => { + api.adminListUsers.mockResolvedValue({ items: [] }) + const w = mount(AdminUsersTab) + await flushPromises() + expect(w.text()).toContain('No users yet') + }) + + it('does NOT show "No users yet" when users are present', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser()], + }) + const w = mount(AdminUsersTab) + await flushPromises() + expect(w.text()).not.toContain('No users yet') + }) +}) + +// ── Deactivate flow ─────────────────────────────────────────────────────────── + +describe('AdminUsersTab — deactivateUser', () => { + it('calls adminDeactivateUser(id) when user confirms deactivation', async () => { + const user = makeUser({ id: 'target-id', is_active: true }) + api.adminListUsers.mockResolvedValue({ items: [user] }) + api.adminDeactivateUser.mockResolvedValue({ is_active: false }) + + const w = mount(AdminUsersTab) + await flushPromises() + + // Click "Deactivate" to enter confirmation state + const deactivateBtn = w.findAll('button').find(b => b.text().trim() === 'Deactivate') + expect(deactivateBtn).toBeTruthy() + await deactivateBtn.trigger('click') + await w.vm.$nextTick() + + // Now click the confirmation "Deactivate" button in the inline panel + const confirmBtn = w.findAll('button').find(b => b.text().trim() === 'Deactivate') + expect(confirmBtn).toBeTruthy() + await confirmBtn.trigger('click') + await flushPromises() + + expect(api.adminDeactivateUser).toHaveBeenCalledWith('target-id') + }) + + it('marks user as inactive in UI after deactivation', async () => { + const user = makeUser({ id: 'u1', is_active: true }) + api.adminListUsers.mockResolvedValue({ items: [user] }) + api.adminDeactivateUser.mockResolvedValue({ is_active: false }) + + const w = mount(AdminUsersTab) + await flushPromises() + + // Trigger deactivate flow + const deactivateBtn = w.findAll('button').find(b => b.text().trim() === 'Deactivate') + await deactivateBtn.trigger('click') + await w.vm.$nextTick() + + const confirmBtn = w.findAll('button').find(b => b.text().trim() === 'Deactivate') + await confirmBtn.trigger('click') + await flushPromises() + + // The row should now show "Deactivated" status + expect(w.text()).toContain('Deactivated') + }) +}) + +// ── Multiple users rendered ─────────────────────────────────────────────────── + +describe('AdminUsersTab — user list rendering', () => { + it('renders all users returned by API', async () => { + api.adminListUsers.mockResolvedValue({ + items: [ + makeUser({ id: 'u1', email: 'alice@example.com' }), + makeUser({ id: 'u2', email: 'bob@example.com' }), + ], + }) + + const w = mount(AdminUsersTab) + await flushPromises() + + expect(w.text()).toContain('alice@example.com') + expect(w.text()).toContain('bob@example.com') + }) + + it('shows user count', async () => { + api.adminListUsers.mockResolvedValue({ + items: [makeUser(), makeUser({ id: 'u2', email: 'b@b.com' })], + }) + + const w = mount(AdminUsersTab) + await flushPromises() + + expect(w.text()).toContain('2 users') + }) +}) diff --git a/frontend/src/components/auth/__tests__/PasswordStrengthBar.test.js b/frontend/src/components/auth/__tests__/PasswordStrengthBar.test.js new file mode 100644 index 0000000..53971a4 --- /dev/null +++ b/frontend/src/components/auth/__tests__/PasswordStrengthBar.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import PasswordStrengthBar from '../PasswordStrengthBar.vue' + +/** + * AUTH-01 (frontend): PasswordStrengthBar strength scoring. + * + * Score algorithm (0–4): + * +1 if length >= 12 + * +1 if /[A-Z]/ + * +1 if /[0-9]/ + * +1 if /[^A-Za-z0-9]/ (special character) + */ + +describe('PasswordStrengthBar — score and visibility', () => { + it('renders nothing when password is empty string', () => { + const w = mount(PasswordStrengthBar, { props: { password: '' } }) + // v-if="password" — empty string is falsy, component is hidden + const bar = w.find('.mt-2') + expect(bar.exists()).toBe(false) + }) + + it('renders nothing when password prop is absent (default empty)', () => { + const w = mount(PasswordStrengthBar) + const bar = w.find('.mt-2') + expect(bar.exists()).toBe(false) + }) + + it('shows strength bar when password is non-empty', () => { + const w = mount(PasswordStrengthBar, { props: { password: 'a' } }) + expect(w.find('.mt-2').exists()).toBe(true) + }) +}) + +describe('PasswordStrengthBar — score 1 (weak)', () => { + it('score 1: long-enough uppercase-only password', () => { + // Only length >= 12 satisfied: "AAAAAAAAAAAA" — uppercase yes, but no digit, no special + // Actually uppercase satisfies /[A-Z]/ → score 2. Use lowercase only >= 12 + // "aaaaaaaaaaaa" — only length passes → score 1 + const w = mount(PasswordStrengthBar, { props: { password: 'aaaaaaaaaaaa' } }) + expect(w.text()).toContain('Too weak') + }) + + it('score 1: short uppercase password with no digit or special', () => { + // "ABC" — only uppercase passes, no length, no digit, no special → score 1 + const w = mount(PasswordStrengthBar, { props: { password: 'ABC' } }) + expect(w.text()).toContain('Too weak') + }) +}) + +describe('PasswordStrengthBar — score 2 (weak)', () => { + it('score 2: length + uppercase only', () => { + // "AAAAAAAAAAAAa" — length>=12 + uppercase: score 2 (no digit, no special) + // Wait: "AAAAAAAAAAAA" — length>=12 AND [A-Z] → score 2 + const w = mount(PasswordStrengthBar, { props: { password: 'AAAAAAAAAAAA' } }) + expect(w.text()).toContain('Weak') + }) +}) + +describe('PasswordStrengthBar — score 3 (fair)', () => { + it('score 3: length + uppercase + digit', () => { + // "AAAAAAAAAAA1" (12 chars) — length>=12, [A-Z], [0-9] → score 3 (no special) + const w = mount(PasswordStrengthBar, { props: { password: 'AAAAAAAAAAA1' } }) + expect(w.text()).toContain('Fair') + }) +}) + +describe('PasswordStrengthBar — score 4 (strong)', () => { + it('score 4: length + uppercase + digit + special', () => { + // "Passw0rd123!" — all four criteria met + const w = mount(PasswordStrengthBar, { props: { password: 'Passw0rd123!' } }) + expect(w.text()).toContain('Strong') + }) + + it('score 4: matches backend AUTH-01 strong password example', () => { + // StrongPass12! — exactly the password used in backend tests + const w = mount(PasswordStrengthBar, { props: { password: 'StrongPass12!' } }) + expect(w.text()).toContain('Strong') + }) +}) + +describe('PasswordStrengthBar — score boundary: short password', () => { + it('11-char password with all other criteria still misses +1 for length', () => { + // "Passw0rd12!" — 11 chars: no length bonus, but uppercase+digit+special → score 3 + const w = mount(PasswordStrengthBar, { props: { password: 'Passw0rd12!' } }) + // score should be 3 (Fair), not 4 (Strong) + expect(w.text()).toContain('Fair') + expect(w.text()).not.toContain('Strong') + }) + + it('12-char password with all criteria → score 4', () => { + // "Passw0rd123!" — exactly 12 chars with all criteria + const w = mount(PasswordStrengthBar, { props: { password: 'Passw0rd123!' } }) + expect(w.text()).toContain('Strong') + }) +}) + +describe('PasswordStrengthBar — visual segments', () => { + it('renders 4 bar segments', () => { + const w = mount(PasswordStrengthBar, { props: { password: 'something' } }) + // 4 segment divs from v-for="i in 4" + const segments = w.findAll('.h-1.flex-1.rounded') + expect(segments.length).toBe(4) + }) + + it('score-1 label is red', () => { + const w = mount(PasswordStrengthBar, { props: { password: 'ABC' } }) + const label = w.find('span.text-xs') + expect(label.classes()).toContain('text-red-500') + }) + + it('score-4 label is green', () => { + const w = mount(PasswordStrengthBar, { props: { password: 'StrongPass12!' } }) + const label = w.find('span.text-xs') + expect(label.classes()).toContain('text-green-500') + }) +}) diff --git a/frontend/src/stores/__tests__/auth.test.js b/frontend/src/stores/__tests__/auth.test.js new file mode 100644 index 0000000..fa1e634 --- /dev/null +++ b/frontend/src/stores/__tests__/auth.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +// Mock api/client.js — no real HTTP calls +vi.mock('../../api/client.js', () => ({ + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + logoutAll: vi.fn(), + refreshToken: vi.fn(), + getMyQuota: vi.fn(), +})) + +import { useAuthStore } from '../auth.js' +import * as api from '../../api/client.js' + +// ── Fake localStorage / sessionStorage to detect any writes ────────────────── +// happy-dom may not provide window.localStorage, so we install our own stubs +// and check whether they were called. + +const fakeLocalStorage = { + _store: {}, + setItem: vi.fn(), + getItem: vi.fn(key => null), + removeItem: vi.fn(), + clear: vi.fn(), +} + +const fakeSessionStorage = { + _store: {}, + setItem: vi.fn(), + getItem: vi.fn(key => null), + removeItem: vi.fn(), + clear: vi.fn(), +} + +// Install stubs globally before tests +Object.defineProperty(globalThis, 'localStorage', { + value: fakeLocalStorage, + writable: true, + configurable: true, +}) + +Object.defineProperty(globalThis, 'sessionStorage', { + value: fakeSessionStorage, + writable: true, + configurable: true, +}) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + // Reset the storage spies + fakeLocalStorage.setItem.mockClear() + fakeSessionStorage.setItem.mockClear() +}) + +afterEach(() => { + // nothing to restore — vi.clearAllMocks() handles it +}) + +// ── Security invariant: no browser storage writes ───────────────────────────── + +describe('useAuthStore — no browser storage writes (security invariant)', () => { + it('login() never writes accessToken to localStorage', async () => { + api.login.mockResolvedValue({ + access_token: 'test-access-token', + user: { id: '1', handle: 'alice', email: 'alice@example.com', role: 'user', totp_enabled: false }, + }) + + const store = useAuthStore() + await store.login('alice@example.com', 'password') + + // accessToken must be in memory, NOT localStorage + expect(fakeLocalStorage.setItem).not.toHaveBeenCalled() + }) + + it('login() never writes accessToken to sessionStorage', async () => { + api.login.mockResolvedValue({ + access_token: 'test-access-token', + user: { id: '1', handle: 'alice', email: 'alice@example.com', role: 'user', totp_enabled: false }, + }) + + const store = useAuthStore() + await store.login('alice@example.com', 'password') + + expect(fakeSessionStorage.setItem).not.toHaveBeenCalled() + }) + + it('login() stores accessToken in memory ref (not null)', async () => { + api.login.mockResolvedValue({ + access_token: 'eyJhbGciOiJIUzI1NiJ9.test', + user: { id: '1', handle: 'alice', email: 'alice@example.com', role: 'user', totp_enabled: false }, + }) + + const store = useAuthStore() + await store.login('alice@example.com', 'password') + + expect(store.accessToken).toBe('eyJhbGciOiJIUzI1NiJ9.test') + }) + + it('logout() clears accessToken from memory without writing to any storage', async () => { + api.login.mockResolvedValue({ + access_token: 'some-token', + user: { id: '1', handle: 'bob', email: 'bob@example.com', role: 'user', totp_enabled: false }, + }) + api.logout.mockResolvedValue(null) + + const store = useAuthStore() + await store.login('bob@example.com', 'pass') + expect(store.accessToken).toBe('some-token') + + await store.logout() + + expect(store.accessToken).toBeNull() + // No storage writes during logout either + expect(fakeLocalStorage.setItem).not.toHaveBeenCalledWith( + expect.stringMatching(/token|auth|access/i), + expect.anything() + ) + }) +}) + +// ── login() passes totp_code to API ────────────────────────────────────────── + +describe('useAuthStore — login() forwards TOTP/backup codes', () => { + it('login() with options.totpCode sends totp_code in API request body', async () => { + api.login.mockResolvedValue({ + access_token: 'tok', + user: { id: '1', handle: 'u', email: 'u@x.com', role: 'user', totp_enabled: true }, + }) + + const store = useAuthStore() + await store.login('u@x.com', 'pass', { totpCode: '123456' }) + + expect(api.login).toHaveBeenCalledWith( + expect.objectContaining({ totp_code: '123456' }) + ) + }) + + it('login() with options.backupCode sends backup_code in API request body', async () => { + api.login.mockResolvedValue({ + access_token: 'tok', + user: { id: '1', handle: 'u', email: 'u@x.com', role: 'user', totp_enabled: true }, + }) + + const store = useAuthStore() + await store.login('u@x.com', 'pass', { backupCode: 'ABC12345' }) + + expect(api.login).toHaveBeenCalledWith( + expect.objectContaining({ backup_code: 'ABC12345' }) + ) + }) + + it('login() without options sends null for both totp_code and backup_code', async () => { + api.login.mockResolvedValue({ + access_token: 'tok', + user: { id: '1', handle: 'u', email: 'u@x.com', role: 'user', totp_enabled: false }, + }) + + const store = useAuthStore() + await store.login('u@x.com', 'pass') + + expect(api.login).toHaveBeenCalledWith( + expect.objectContaining({ + totp_code: null, + backup_code: null, + }) + ) + }) + + it('login() returns { requires_totp: true } when server demands TOTP', async () => { + api.login.mockResolvedValue({ requires_totp: true }) + + const store = useAuthStore() + const result = await store.login('u@x.com', 'pass') + + expect(result).toEqual({ requires_totp: true }) + // accessToken must remain null — no tokens were issued + expect(store.accessToken).toBeNull() + }) + + it('login() returns { requires_password_change: true, user_id } when forced change', async () => { + api.login.mockResolvedValue({ requires_password_change: true, user_id: 'uid-99' }) + + const store = useAuthStore() + const result = await store.login('u@x.com', 'pass') + + expect(result).toEqual({ requires_password_change: true, user_id: 'uid-99' }) + expect(store.accessToken).toBeNull() + }) +}) + +// ── login() field mapping edge cases ───────────────────────────────────────── + +describe('useAuthStore — login() API field mapping', () => { + it('sends email and password as-is', async () => { + api.login.mockResolvedValue({ + access_token: 'tok', + user: { id: '1', handle: 'u', email: 'test@example.com', role: 'user', totp_enabled: false }, + }) + + const store = useAuthStore() + await store.login('test@example.com', 'S3cr3tP@ss!') + + expect(api.login).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + password: 'S3cr3tP@ss!', + }) + ) + }) + + it('sets user in state after successful login', async () => { + const userData = { id: 'user-1', handle: 'alice', email: 'alice@example.com', role: 'user', totp_enabled: false } + api.login.mockResolvedValue({ access_token: 'tok', user: userData }) + + const store = useAuthStore() + await store.login('alice@example.com', 'pass') + + expect(store.user).toEqual(userData) + }) +})