From 8f8bfa5539a4f6d25a5a64ad9b94fd70b2af93c1 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 5 Jun 2026 12:54:14 +0200 Subject: [PATCH] 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 --- .../auth/__tests__/TotpEnrollment.test.js | 67 +++++++++++++ .../__tests__/SettingsAccountTab.test.js | 94 ++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/auth/__tests__/TotpEnrollment.test.js b/frontend/src/components/auth/__tests__/TotpEnrollment.test.js index 48126ba..4fa27e1 100644 --- a/frontend/src/components/auth/__tests__/TotpEnrollment.test.js +++ b/frontend/src/components/auth/__tests__/TotpEnrollment.test.js @@ -18,6 +18,7 @@ vi.mock('../../../api/client.js', () => ({ totpEnable: vi.fn(), })) +import { totpEnable as totpEnableMock } from '../../../api/client.js' import TotpEnrollment from '../TotpEnrollment.vue' beforeEach(() => { @@ -80,3 +81,69 @@ describe('TotpEnrollment — QR code rendering (AUTH-03)', () => { expect(wrapper.text()).toContain('JBSWY3DPEHPK3PXP') }) }) + +// ─── GAP 3: Sessions revoked inline alert (CR-02) ─────────────────────────── + +describe('TotpEnrollment — sessions revoked toast (CR-02)', () => { + const mountStubs = { + AppSpinner: { template: '' }, + BackupCodesDisplay: { template: '
', 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.') + }) +}) diff --git a/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js b/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js index 9cf2b10..584a280 100644 --- a/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js +++ b/frontend/src/components/settings/__tests__/SettingsAccountTab.test.js @@ -1,5 +1,5 @@ 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 { createRouter, createMemoryHistory } from 'vue-router' @@ -15,6 +15,7 @@ vi.mock('../../../api/client.js', () => ({ })) import { useAuthStore } from '../../../stores/auth.js' +import { changePassword as changePasswordMock, totpDisable as totpDisableMock } from '../../../api/client.js' import SettingsAccountTab from '../SettingsAccountTab.vue' // Minimal router — required because the component calls useRouter() in script setup @@ -39,6 +40,97 @@ beforeEach(() => { 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: '
', + 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)', () => { it('renders all 4 required section headings when totp_enabled is false', () => { useAuthStore.mockReturnValue({