diff --git a/frontend/src/components/auth/__tests__/TotpEnrollment.test.js b/frontend/src/components/auth/__tests__/TotpEnrollment.test.js
new file mode 100644
index 0000000..48126ba
--- /dev/null
+++ b/frontend/src/components/auth/__tests__/TotpEnrollment.test.js
@@ -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
tag with a data:image/ src after startSetup, not an otpauth:// link', async () => {
+ const wrapper = mount(TotpEnrollment, {
+ global: {
+ plugins: [createPinia()],
+ stubs: {
+ AppSpinner: { template: '' },
+ BackupCodesDisplay: { template: '
', 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:
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 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: '' },
+ BackupCodesDisplay: { template: '', props: ['codes'] },
+ },
+ },
+ })
+
+ await wrapper.find('button').trigger('click')
+ await flushPromises()
+
+ // The secret JBSWY3DPEHPK3PXP must appear in the rendered output
+ expect(wrapper.text()).toContain('JBSWY3DPEHPK3PXP')
+ })
+})
diff --git a/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js b/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js
new file mode 100644
index 0000000..9cf2b10
--- /dev/null
+++ b/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js
@@ -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: '' } },
+ { path: '/login', component: { template: '' } },
+ { path: '/settings', component: { template: '' } },
+ ],
+})
+
+const globalStubs = {
+ TotpEnrollment: { template: '' },
+ ConfirmBlock: { template: '', props: ['message', 'confirmLabel', 'cancelLabel'] },
+ PasswordStrengthBar: { template: '', props: ['password'] },
+ AppSpinner: { template: '' },
+}
+
+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)
+ })
+})
diff --git a/frontend/src/router/__tests__/router.guard.test.js b/frontend/src/router/__tests__/router.guard.test.js
new file mode 100644
index 0000000..99336fc
--- /dev/null
+++ b/frontend/src/router/__tests__/router.guard.test.js
@@ -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: '' } }))
+vi.mock('../../views/auth/RegisterView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/auth/PasswordResetView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/auth/NewPasswordView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/AdminView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/SharedView.vue', () => ({ default: { template: '' } }))
+
+// Heavy view components imported statically — stub them too
+vi.mock('../../views/FileManagerView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/TopicsView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/DocumentView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/SettingsView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/CloudFolderView.vue', () => ({ default: { template: '' } }))
+vi.mock('../../views/CloudStorageView.vue', () => ({ default: { template: '' } }))
+
+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')
+ })
+})