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:
co-authored by
Claude Sonnet 4.6
parent
6c79f92d70
commit
d98e3ab7a1
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user