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