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:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user