diff --git a/backend/api/auth_api.py b/backend/api/auth_api.py index a728096..3a9dca4 100644 --- a/backend/api/auth_api.py +++ b/backend/api/auth_api.py @@ -364,7 +364,7 @@ def reset_password(): refresh_tokens_db.remove(TokenQuery.user_id == user.id) # Notify all active sessions (other tabs/devices) to sign out immediately - send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({}))) + send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'}))) resp = jsonify({'message': 'Password has been reset'}) _clear_auth_cookies(resp) diff --git a/backend/api/user_api.py b/backend/api/user_api.py index 7408bee..8e9e06e 100644 --- a/backend/api/user_api.py +++ b/backend/api/user_api.py @@ -9,6 +9,8 @@ import string import utils.email_sender as email_sender from datetime import datetime, timedelta, timezone from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user +from events.sse import send_event_to_user +from events.types.payload import Payload from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED from events.types.event_types import EventType from events.types.event import Event @@ -242,5 +244,8 @@ def mark_for_deletion(): # Trigger SSE event send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE))) - + + # Notify all other active sessions to sign out and go to landing page + send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'}))) + return jsonify({'success': True}), 200 diff --git a/frontend/vue-app/src/__tests__/UserProfile.spec.ts b/frontend/vue-app/src/__tests__/UserProfile.spec.ts index ed4a574..7fa6613 100644 --- a/frontend/vue-app/src/__tests__/UserProfile.spec.ts +++ b/frontend/vue-app/src/__tests__/UserProfile.spec.ts @@ -17,9 +17,13 @@ const mockRouter = createRouter({ }) // Mock auth store -const mockLogoutUser = vi.fn() +const { mockLogoutUser, mockSuppressForceLogout } = vi.hoisted(() => ({ + mockLogoutUser: vi.fn(), + mockSuppressForceLogout: { value: false }, +})) vi.mock('../stores/auth', () => ({ logoutUser: () => mockLogoutUser(), + suppressForceLogout: mockSuppressForceLogout, })) describe('UserProfile - Delete Account', () => { @@ -147,6 +151,64 @@ describe('UserProfile - Delete Account', () => { expect(wrapper.vm.showDeleteSuccess).toBe(true) }) + it('sets suppressForceLogout before making the API call', async () => { + let flagDuringRequest = false + ;(global.fetch as any).mockImplementationOnce(async () => { + flagDuringRequest = mockSuppressForceLogout.value + return { ok: true, json: async () => ({ success: true }) } + }) + + wrapper.vm.initialData.email = 'test@example.com' + wrapper.vm.confirmEmail = 'test@example.com' + + await wrapper.vm.confirmDeleteAccount() + + expect(flagDuringRequest).toBe(true) + }) + + it('leaves suppressForceLogout set after successful deletion', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }) + + wrapper.vm.initialData.email = 'test@example.com' + wrapper.vm.confirmEmail = 'test@example.com' + + await wrapper.vm.confirmDeleteAccount() + await nextTick() + + expect(mockSuppressForceLogout.value).toBe(true) + }) + + it('clears suppressForceLogout on API failure', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: 'fail', code: 'ERROR' }), + }) + + wrapper.vm.initialData.email = 'test@example.com' + wrapper.vm.confirmEmail = 'test@example.com' + + await wrapper.vm.confirmDeleteAccount() + await nextTick() + + expect(mockSuppressForceLogout.value).toBe(false) + }) + + it('clears suppressForceLogout on network error', async () => { + ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) + + wrapper.vm.initialData.email = 'test@example.com' + wrapper.vm.confirmEmail = 'test@example.com' + + await wrapper.vm.confirmDeleteAccount() + await nextTick() + + expect(mockSuppressForceLogout.value).toBe(false) + }) + it('shows error modal on API failure', async () => { const mockResponse = { ok: false, diff --git a/frontend/vue-app/src/components/BackendEventsListener.vue b/frontend/vue-app/src/components/BackendEventsListener.vue index d2d1839..dd47010 100644 --- a/frontend/vue-app/src/components/BackendEventsListener.vue +++ b/frontend/vue-app/src/components/BackendEventsListener.vue @@ -2,7 +2,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { useBackendEvents } from '@/common/backendEvents' -import { currentUserId, logoutUser } from '@/stores/auth' +import { currentUserId, logoutUser, suppressForceLogout } from '@/stores/auth' import { eventBus } from '@/common/eventBus' const userId = ref(currentUserId.value) @@ -15,9 +15,17 @@ watch(currentUserId, (id) => { // Always call useBackendEvents in setup, passing the reactive userId useBackendEvents(userId) -function handleForceLogout() { +function handleForceLogout(event: { payload?: { reason?: string } }) { + if (suppressForceLogout.value) { + suppressForceLogout.value = false + return + } logoutUser() - router.push('/') + if (event?.payload?.reason === 'account_deleted') { + router.push('/') + } else { + router.push({ name: 'Login' }) + } } onMounted(() => { diff --git a/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts b/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts new file mode 100644 index 0000000..df4c013 --- /dev/null +++ b/frontend/vue-app/src/components/__tests__/BackendEventsListener.spec.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import BackendEventsListener from '../BackendEventsListener.vue' + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { mockLogoutUser, mockSuppressForceLogout, mockCurrentUserId } = vi.hoisted(() => ({ + mockLogoutUser: vi.fn(), + mockSuppressForceLogout: { value: false }, + mockCurrentUserId: { value: 'user-1' }, +})) + +let capturedForceLogoutHandler: ((event: any) => void) | null = null + +vi.mock('@/stores/auth', () => ({ + currentUserId: mockCurrentUserId, + logoutUser: mockLogoutUser, + suppressForceLogout: mockSuppressForceLogout, +})) + +vi.mock('@/common/backendEvents', () => ({ + useBackendEvents: vi.fn(), +})) + +vi.mock('@/common/eventBus', () => ({ + eventBus: { + on: vi.fn((event: string, handler: (e: any) => void) => { + if (event === 'force_logout') capturedForceLogoutHandler = handler + }), + off: vi.fn(), + }, +})) + +// ── Router ─────────────────────────────────────────────────────────────────── + +const mockRouter = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'Landing', component: { template: '
' } }, + { path: '/auth/login', name: 'Login', component: { template: '
' } }, + ], +}) + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function mountListener() { + return mount(BackendEventsListener, { + global: { plugins: [mockRouter] }, + }) +} + +function fireForceLogout(reason?: string) { + capturedForceLogoutHandler?.({ payload: reason ? { reason } : {} }) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('BackendEventsListener – force_logout', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedForceLogoutHandler = null + mockSuppressForceLogout.value = false + }) + + it('navigates to Login on force_logout with reason password_reset', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + + fireForceLogout('password_reset') + await flushPromises() + + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) + }) + + it('navigates to / on force_logout with reason account_deleted', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + + fireForceLogout('account_deleted') + await flushPromises() + + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(pushSpy).toHaveBeenCalledWith('/') + }) + + it('navigates to Login on force_logout with unknown reason', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + + fireForceLogout('something_else') + await flushPromises() + + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) + }) + + it('navigates to Login on force_logout with no reason', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + + fireForceLogout() + await flushPromises() + + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) + }) + + it('skips logout and navigation when suppressForceLogout is true', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + mockSuppressForceLogout.value = true + + fireForceLogout('password_reset') + await flushPromises() + + expect(mockLogoutUser).not.toHaveBeenCalled() + expect(pushSpy).not.toHaveBeenCalled() + }) + + it('clears suppressForceLogout after consuming a suppressed event', async () => { + mountListener() + mockSuppressForceLogout.value = true + + fireForceLogout('password_reset') + await flushPromises() + + expect(mockSuppressForceLogout.value).toBe(false) + }) + + it('processes the next force_logout normally after suppression is consumed', async () => { + mountListener() + const pushSpy = vi.spyOn(mockRouter, 'push') + mockSuppressForceLogout.value = true + + // First event: suppressed + fireForceLogout('password_reset') + await flushPromises() + expect(mockLogoutUser).not.toHaveBeenCalled() + + // Second event: should go through normally + fireForceLogout('password_reset') + await flushPromises() + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(pushSpy).toHaveBeenCalledWith({ name: 'Login' }) + }) +}) diff --git a/frontend/vue-app/src/components/auth/__tests__/ResetPassword.spec.ts b/frontend/vue-app/src/components/auth/__tests__/ResetPassword.spec.ts index 5b2edec..fa3ae5e 100644 --- a/frontend/vue-app/src/components/auth/__tests__/ResetPassword.spec.ts +++ b/frontend/vue-app/src/components/auth/__tests__/ResetPassword.spec.ts @@ -1,12 +1,164 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' +import ResetPassword from '../ResetPassword.vue' -describe('ResetPassword.vue', () => { - it('calls /api/auth/validate-reset-token endpoint (not /api/validate-reset-token)', () => { - // This test verifies that the component uses the /auth prefix - // The actual functionality is tested by the integration with the backend - // which is working correctly (183 backend tests passing) +// ── Mocks ──────────────────────────────────────────────────────────────────── - // Verify that ResetPassword imports are working - expect(true).toBe(true) +global.fetch = vi.fn() + +const { mockLogoutUser } = vi.hoisted(() => ({ mockLogoutUser: vi.fn() })) +vi.mock('@/stores/auth', () => ({ logoutUser: mockLogoutUser })) +vi.mock('@/common/api', () => ({ + isPasswordStrong: (p: string) => p.length >= 8 && /[a-zA-Z]/.test(p) && /[0-9]/.test(p), +})) + +const mockRouter = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/auth/login', name: 'Login', component: { template: '
' } }, + { path: '/auth', name: 'AuthLanding', component: { template: '
' } }, + { path: '/auth/reset-password', name: 'ResetPassword', component: { template: '
' } }, + ], +}) + +function mountWithToken(token = 'valid-token') { + return mount(ResetPassword, { + global: { + plugins: [mockRouter], + stubs: { ModalDialog: { template: '' } }, + }, + }) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('ResetPassword', () => { + beforeEach(async () => { + vi.clearAllMocks() + ;(global.fetch as any).mockClear() + // navigate to the route with a token query param + await mockRouter.push('/auth/reset-password?token=valid-token') + }) + + it('validates the token on mount and shows the form when valid', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }) + const wrapper = mountWithToken() + await flushPromises() + + expect(wrapper.vm.tokenValid).toBe(true) + expect(wrapper.vm.tokenChecked).toBe(true) + }) + + it('shows error and redirects when token is invalid', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: 'Token expired' }), + }) + const pushSpy = vi.spyOn(mockRouter, 'push') + mountWithToken() + await flushPromises() + + expect(pushSpy).toHaveBeenCalledWith({ name: 'AuthLanding' }) + }) + + it('shows error and redirects on network failure during token check', async () => { + ;(global.fetch as any).mockRejectedValueOnce(new Error('Network')) + const pushSpy = vi.spyOn(mockRouter, 'push') + mountWithToken() + await flushPromises() + + expect(pushSpy).toHaveBeenCalledWith({ name: 'AuthLanding' }) + }) + + it('submit button is disabled when passwords are weak or mismatched', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }) + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'weak' + wrapper.vm.confirmPassword = 'weak' + await nextTick() + + expect(wrapper.vm.formValid).toBeFalsy() + }) + + it('submit button is enabled when passwords are strong and match', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }) + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'StrongPass1' + wrapper.vm.confirmPassword = 'StrongPass1' + await nextTick() + + expect(wrapper.vm.formValid).toBeTruthy() + }) + + it('calls /api/auth/reset-password with token and new password on submit', async () => { + ;(global.fetch as any) + .mockResolvedValueOnce({ ok: true }) // token validation + .mockResolvedValueOnce({ ok: true }) // reset call + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'NewPass123' + wrapper.vm.confirmPassword = 'NewPass123' + await wrapper.vm.submitForm() + + expect(global.fetch).toHaveBeenLastCalledWith( + '/api/auth/reset-password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ token: 'valid-token', password: 'NewPass123' }), + }), + ) + }) + + it('shows success modal and calls logoutUser after successful reset', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true }) + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'NewPass123' + wrapper.vm.confirmPassword = 'NewPass123' + await wrapper.vm.submitForm() + await nextTick() + + expect(mockLogoutUser).toHaveBeenCalledOnce() + expect(wrapper.vm.showModal).toBe(true) + }) + + it('shows error message on failed reset', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ + ok: false, + json: async () => ({ error: 'Token expired' }), + text: async () => '', + }) + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'NewPass123' + wrapper.vm.confirmPassword = 'NewPass123' + await wrapper.vm.submitForm() + await nextTick() + + expect(wrapper.vm.errorMsg).toBe('Token expired') + expect(wrapper.vm.showModal).toBe(false) + }) + + it('clears password fields after successful reset', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true }) + const wrapper = mountWithToken() + await flushPromises() + + wrapper.vm.password = 'NewPass123' + wrapper.vm.confirmPassword = 'NewPass123' + await wrapper.vm.submitForm() + await nextTick() + + expect(wrapper.vm.password).toBe('') + expect(wrapper.vm.confirmPassword).toBe('') }) }) diff --git a/frontend/vue-app/src/components/profile/UserProfile.vue b/frontend/vue-app/src/components/profile/UserProfile.vue index 208d6d9..ef08e4c 100644 --- a/frontend/vue-app/src/components/profile/UserProfile.vue +++ b/frontend/vue-app/src/components/profile/UserProfile.vue @@ -103,7 +103,7 @@ import EntityEditForm from '../shared/EntityEditForm.vue' import ModalDialog from '../shared/ModalDialog.vue' import { parseErrorResponse, isEmailValid } from '@/common/api' import { ALREADY_MARKED } from '@/common/errorCodes' -import { logoutUser } from '@/stores/auth' +import { logoutUser, suppressForceLogout } from '@/stores/auth' import '@/assets/styles.css' const router = useRouter() @@ -300,6 +300,9 @@ function closeDeleteWarning() { async function confirmDeleteAccount() { if (!isEmailValid(confirmEmail.value)) return + // Set flag before the request so it's guaranteed to be set + // before the force_logout SSE event can arrive on this tab + suppressForceLogout.value = true deletingAccount.value = true try { const res = await fetch('/api/user/mark-for-deletion', { @@ -309,6 +312,7 @@ async function confirmDeleteAccount() { }) if (!res.ok) { + suppressForceLogout.value = false const { msg, code } = await parseErrorResponse(res) let errorMessage = msg if (code === ALREADY_MARKED) { @@ -320,10 +324,11 @@ async function confirmDeleteAccount() { return } - // Success + // Success — suppressForceLogout is already set; show confirmation modal showDeleteWarning.value = false showDeleteSuccess.value = true } catch { + suppressForceLogout.value = false deleteErrorMessage.value = 'Network error. Please try again.' showDeleteWarning.value = false showDeleteError.value = true diff --git a/frontend/vue-app/src/stores/auth.ts b/frontend/vue-app/src/stores/auth.ts index c4203ec..a945f22 100644 --- a/frontend/vue-app/src/stores/auth.ts +++ b/frontend/vue-app/src/stores/auth.ts @@ -34,6 +34,7 @@ if (hasLocalStorage) { export const isUserLoggedIn = ref(false) export const isAuthReady = ref(false) export const currentUserId = ref('') +export const suppressForceLogout = ref(false) let authSyncInitialized = false // --- Background expiry watcher ---