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:
@@ -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 (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')
|
||||
})
|
||||
})
|
||||
@@ -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