3 positive + 2 negative Vitest tests covering CR-01/02/03 toast UX: - SettingsAccountTab: toast appears/hidden after changePassword (CR-01) - SettingsAccountTab: toast appears after disableTotp (CR-03) - TotpEnrollment: inline alert appears/hidden after enable_totp (CR-02) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
4.9 KiB
JavaScript
150 lines
4.9 KiB
JavaScript
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 { totpEnable as totpEnableMock } from '../../../api/client.js'
|
|
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')
|
|
})
|
|
})
|
|
|
|
// ─── GAP 3: Sessions revoked inline alert (CR-02) ───────────────────────────
|
|
|
|
describe('TotpEnrollment — sessions revoked toast (CR-02)', () => {
|
|
const mountStubs = {
|
|
AppSpinner: { template: '<span />' },
|
|
BackupCodesDisplay: { template: '<div />', props: ['codes'] },
|
|
}
|
|
|
|
it('renders "Other sessions have been terminated." after totpEnable returns sessions_revoked > 0 (CR-02)', async () => {
|
|
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
|
backup_codes: ['CODE1', 'CODE2'],
|
|
sessions_revoked: 1,
|
|
})
|
|
|
|
const wrapper = mount(TotpEnrollment, {
|
|
global: {
|
|
plugins: [createPinia()],
|
|
stubs: mountStubs,
|
|
},
|
|
})
|
|
|
|
// Move component from 'setup' step → 'verify' step by clicking the setup button
|
|
await wrapper.find('button').trigger('click')
|
|
await flushPromises()
|
|
|
|
// Now in 'verify' step — find the code input and set a 6-digit value
|
|
const codeInput = wrapper.find('input[inputmode="numeric"]')
|
|
expect(codeInput.exists()).toBe(true)
|
|
await codeInput.setValue('123456')
|
|
|
|
// Click "Verify code" button to trigger confirmEnrollment()
|
|
const verifyBtn = wrapper.findAll('button').find(b => b.text().includes('Verify code'))
|
|
expect(verifyBtn).toBeDefined()
|
|
await verifyBtn.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
|
})
|
|
|
|
it('does NOT render the alert when totpEnable returns sessions_revoked is 0 (CR-02 negative)', async () => {
|
|
vi.mocked(totpEnableMock).mockResolvedValueOnce({
|
|
backup_codes: [],
|
|
sessions_revoked: 0,
|
|
})
|
|
|
|
const wrapper = mount(TotpEnrollment, {
|
|
global: {
|
|
plugins: [createPinia()],
|
|
stubs: mountStubs,
|
|
},
|
|
})
|
|
|
|
await wrapper.find('button').trigger('click')
|
|
await flushPromises()
|
|
|
|
const codeInput = wrapper.find('input[inputmode="numeric"]')
|
|
await codeInput.setValue('123456')
|
|
|
|
const verifyBtn = wrapper.findAll('button').find(b => b.text().includes('Verify code'))
|
|
await verifyBtn.trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(wrapper.text()).not.toContain('Other sessions have been terminated.')
|
|
})
|
|
})
|