test(phase-07.1): add Nyquist validation tests for sessions_revoked frontend behavior
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
89375e6d93
commit
8f8bfa5539
@@ -18,6 +18,7 @@ vi.mock('../../../api/client.js', () => ({
|
|||||||
totpEnable: vi.fn(),
|
totpEnable: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
import { totpEnable as totpEnableMock } from '../../../api/client.js'
|
||||||
import TotpEnrollment from '../TotpEnrollment.vue'
|
import TotpEnrollment from '../TotpEnrollment.vue'
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -80,3 +81,69 @@ describe('TotpEnrollment — QR code rendering (AUTH-03)', () => {
|
|||||||
expect(wrapper.text()).toContain('JBSWY3DPEHPK3PXP')
|
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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ vi.mock('../../../api/client.js', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { useAuthStore } from '../../../stores/auth.js'
|
import { useAuthStore } from '../../../stores/auth.js'
|
||||||
|
import { changePassword as changePasswordMock, totpDisable as totpDisableMock } from '../../../api/client.js'
|
||||||
import SettingsAccountTab from '../SettingsAccountTab.vue'
|
import SettingsAccountTab from '../SettingsAccountTab.vue'
|
||||||
|
|
||||||
// Minimal router — required because the component calls useRouter() in script setup
|
// Minimal router — required because the component calls useRouter() in script setup
|
||||||
@@ -39,6 +40,97 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── GAP 1 & 2: Sessions revoked toast (CR-01, CR-03) ──────────────────────
|
||||||
|
|
||||||
|
describe('SettingsAccountTab — sessions revoked toast (CR-01, CR-03)', () => {
|
||||||
|
// Stub for ConfirmBlock that renders a button triggering @confirmed emission
|
||||||
|
const ConfirmBlockEmitter = {
|
||||||
|
name: 'ConfirmBlock',
|
||||||
|
template: '<div data-stub="ConfirmBlock"><button data-action="confirm" @click="$emit(\'confirmed\')">confirm</button></div>',
|
||||||
|
props: ['message', 'confirmLabel', 'cancelLabel'],
|
||||||
|
emits: ['confirmed', 'cancelled'],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows "Other sessions have been terminated." toast after changePassword returns sessions_revoked > 0 (CR-01)', async () => {
|
||||||
|
useAuthStore.mockReturnValue({
|
||||||
|
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
||||||
|
logoutAll: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(changePasswordMock).mockResolvedValueOnce({
|
||||||
|
message: 'Password updated',
|
||||||
|
sessions_revoked: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsAccountTab, {
|
||||||
|
global: {
|
||||||
|
plugins: [createPinia(), testRouter],
|
||||||
|
stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit the change-password form to invoke changePassword()
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT show toast after changePassword when sessions_revoked is 0 (CR-01 negative)', async () => {
|
||||||
|
useAuthStore.mockReturnValue({
|
||||||
|
user: { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: false },
|
||||||
|
logoutAll: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(changePasswordMock).mockResolvedValueOnce({
|
||||||
|
message: 'Password updated',
|
||||||
|
sessions_revoked: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsAccountTab, {
|
||||||
|
global: {
|
||||||
|
plugins: [createPinia(), testRouter],
|
||||||
|
stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Other sessions have been terminated.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Other sessions have been terminated." toast after disableTotp returns sessions_revoked > 0 (CR-03)', async () => {
|
||||||
|
const user = { email: 'test@example.com', handle: 'testuser', role: 'user', totp_enabled: true }
|
||||||
|
useAuthStore.mockReturnValue({
|
||||||
|
user,
|
||||||
|
logoutAll: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(totpDisableMock).mockResolvedValueOnce({
|
||||||
|
message: 'TOTP disabled',
|
||||||
|
sessions_revoked: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(SettingsAccountTab, {
|
||||||
|
global: {
|
||||||
|
plugins: [createPinia(), testRouter],
|
||||||
|
stubs: { ...globalStubs, ConfirmBlock: ConfirmBlockEmitter },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Click "Disable 2FA" to reveal the ConfirmBlock
|
||||||
|
const disableBtn = wrapper.findAll('button').find(b => b.text().includes('Disable 2FA'))
|
||||||
|
await disableBtn.trigger('click')
|
||||||
|
|
||||||
|
// Click the confirm button rendered by the ConfirmBlockEmitter stub
|
||||||
|
await wrapper.find('[data-action="confirm"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Other sessions have been terminated.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SettingsAccountTab — four section headings (AUTH-03, AUTH-04)', () => {
|
describe('SettingsAccountTab — four section headings (AUTH-03, AUTH-04)', () => {
|
||||||
it('renders all 4 required section headings when totp_enabled is false', () => {
|
it('renders all 4 required section headings when totp_enabled is false', () => {
|
||||||
useAuthStore.mockReturnValue({
|
useAuthStore.mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user