test(phase-02): add Nyquist validation tests for plan 06 gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-01 15:17:25 +02:00
parent da526cb727
commit 0505beb0a4
3 changed files with 279 additions and 0 deletions
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// Mock qrcode before any imports — avoids canvas rendering in happy-dom
vi.mock('qrcode', () => ({
default: {
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,fakeqr'),
},
}))
// Mock API client
vi.mock('../../../api/client.js', () => ({
totpSetup: vi.fn().mockResolvedValue({
provisioning_uri: 'otpauth://totp/DocuVault:test@example.com?secret=JBSWY3DPEHPK3PXP',
secret: 'JBSWY3DPEHPK3PXP',
}),
totpEnable: vi.fn(),
}))
import TotpEnrollment from '../TotpEnrollment.vue'
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('TotpEnrollment — QR code rendering (AUTH-03)', () => {
it('renders an <img> tag with a data:image/ src after startSetup, not an otpauth:// link', async () => {
const wrapper = mount(TotpEnrollment, {
global: {
plugins: [createPinia()],
stubs: {
AppSpinner: { template: '<span />' },
BackupCodesDisplay: { template: '<div />', props: ['codes'] },
},
},
})
// Initial state — verify step not shown yet
expect(wrapper.find('img').exists()).toBe(false)
// Find and click the setup button
const setupButton = wrapper.find('button')
expect(setupButton.exists()).toBe(true)
expect(setupButton.text()).toContain('Set up two-factor authentication')
await setupButton.trigger('click')
// Wait for all async operations (totpSetup + QRCode.toDataURL)
await flushPromises()
// Assert: <img> must exist with a data:image/ src
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toMatch(/^data:image\//)
// Assert: no <a> tag with href starting with otpauth:// (the old link-based approach)
const links = wrapper.findAll('a')
const otpauthLinks = links.filter(a =>
(a.attributes('href') || '').startsWith('otpauth://')
)
expect(otpauthLinks).toHaveLength(0)
})
it('displays the manual secret key after startSetup', async () => {
const wrapper = mount(TotpEnrollment, {
global: {
plugins: [createPinia()],
stubs: {
AppSpinner: { template: '<span />' },
BackupCodesDisplay: { template: '<div />', props: ['codes'] },
},
},
})
await wrapper.find('button').trigger('click')
await flushPromises()
// The secret JBSWY3DPEHPK3PXP must appear in the rendered output
expect(wrapper.text()).toContain('JBSWY3DPEHPK3PXP')
})
})
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
// Mock auth store before any imports
vi.mock('../../../stores/auth.js', () => ({
useAuthStore: vi.fn(),
}))
// Mock api/client to avoid HTTP calls
vi.mock('../../../api/client.js', () => ({
changePassword: vi.fn(),
totpDisable: vi.fn(),
}))
import { useAuthStore } from '../../../stores/auth.js'
import SettingsAccountTab from '../SettingsAccountTab.vue'
// Minimal router — required because the component calls useRouter() in script setup
const testRouter = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/login', component: { template: '<div />' } },
{ path: '/settings', component: { template: '<div />' } },
],
})
const globalStubs = {
TotpEnrollment: { template: '<div data-stub="TotpEnrollment" />' },
ConfirmBlock: { template: '<div data-stub="ConfirmBlock" />', props: ['message', 'confirmLabel', 'cancelLabel'] },
PasswordStrengthBar: { template: '<div data-stub="PasswordStrengthBar" />', props: ['password'] },
AppSpinner: { template: '<span />' },
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('SettingsAccountTab — four section headings (AUTH-03, AUTH-04)', () => {
it('renders all 4 required section headings when totp_enabled is false', () => {
useAuthStore.mockReturnValue({
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
logoutAll: vi.fn(),
})
const wrapper = mount(SettingsAccountTab, {
global: {
plugins: [createPinia(), testRouter],
stubs: globalStubs,
},
})
const text = wrapper.text()
expect(text).toContain('Account information')
expect(text).toContain('Two-factor authentication')
expect(text).toContain('Change password')
expect(text).toContain('Sessions')
})
it('renders TotpEnrollment stub when totp_enabled is false', () => {
useAuthStore.mockReturnValue({
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
logoutAll: vi.fn(),
})
const wrapper = mount(SettingsAccountTab, {
global: {
plugins: [createPinia(), testRouter],
stubs: globalStubs,
},
})
expect(wrapper.find('[data-stub="TotpEnrollment"]').exists()).toBe(true)
})
it('shows "Enabled" status and "Disable 2FA" button when totp_enabled is true', () => {
useAuthStore.mockReturnValue({
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true },
logoutAll: vi.fn(),
})
const wrapper = mount(SettingsAccountTab, {
global: {
plugins: [createPinia(), testRouter],
stubs: globalStubs,
},
})
// "Enabled" text must appear (inside the 2FA section)
expect(wrapper.text()).toContain('Enabled')
// "Disable 2FA" button must be present
const buttons = wrapper.findAll('button')
const disableBtn = buttons.find(b => b.text().includes('Disable 2FA'))
expect(disableBtn).toBeDefined()
})
it('does NOT render TotpEnrollment stub when totp_enabled is true', () => {
useAuthStore.mockReturnValue({
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true },
logoutAll: vi.fn(),
})
const wrapper = mount(SettingsAccountTab, {
global: {
plugins: [createPinia(), testRouter],
stubs: globalStubs,
},
})
expect(wrapper.find('[data-stub="TotpEnrollment"]').exists()).toBe(false)
})
})
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
// Must mock before importing router (which imports useAuthStore)
vi.mock('../../stores/auth.js', () => ({
useAuthStore: vi.fn(),
}))
// Mock all lazily-imported view components so the router can be instantiated
vi.mock('../../views/auth/LoginView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/auth/NewPasswordView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/AdminView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/SharedView.vue', () => ({ default: { template: '<div />' } }))
// Heavy view components imported statically — stub them too
vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/CloudFolderView.vue', () => ({ default: { template: '<div />' } }))
vi.mock('../../views/CloudStorageView.vue', () => ({ default: { template: '<div />' } }))
import { useAuthStore } from '../../stores/auth.js'
import router from '../index.js'
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('router — auth route meta (SEC-07, AUTH-01)', () => {
const authPaths = ['/login', '/register', '/password-reset', '/password-reset/confirm']
it.each(authPaths)(
'route %s has meta.public = true so guard skips auth check',
(path) => {
const route = router.getRoutes().find(r => r.path === path)
expect(route, `route "${path}" should exist`).toBeDefined()
expect(route.meta.public).toBe(true)
}
)
it.each(authPaths)(
'route %s has meta.layout = "auth" so sidebar is never rendered',
(path) => {
const route = router.getRoutes().find(r => r.path === path)
expect(route, `route "${path}" should exist`).toBeDefined()
expect(route.meta.layout).toBe('auth')
}
)
})
describe('router — admin guard (SEC-07)', () => {
it('redirects a non-admin user away from /admin to /', async () => {
// Arrange: authenticated but role = 'viewer'
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '1', role: 'viewer' },
refresh: vi.fn().mockResolvedValue(undefined),
})
// Act: attempt to navigate to /admin
const result = await router.push('/admin')
// The guard returns { path: '/' }, so the router resolves to '/'
expect(router.currentRoute.value.path).toBe('/')
})
it('allows an admin user to reach /admin', async () => {
// Arrange: authenticated as admin
useAuthStore.mockReturnValue({
accessToken: 'fake-token',
user: { id: '1', role: 'admin' },
refresh: vi.fn().mockResolvedValue(undefined),
})
await router.push('/admin')
expect(router.currentRoute.value.path).toBe('/admin')
})
})