test(phase-02): add Nyquist validation tests — fill SEC-05, AUTH-08, SEC-03 and frontend gaps

8 test files, 60 new tests (14 backend + 46 frontend). All green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-31 12:04:21 +02:00
parent 6c79f92d70
commit d98e3ab7a1
8 changed files with 1381 additions and 0 deletions
+188
View File
@@ -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."
)
+182
View File
@@ -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)
+239
View File
@@ -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."
)
@@ -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)
})
})
@@ -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)
})
})
@@ -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')
})
})
@@ -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 (04):
* +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')
})
})
+223
View File
@@ -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)
})
})