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') + }) +})