diff --git a/e2e/tests/sync/supersync-account-deletion.spec.ts b/e2e/tests/sync/supersync-account-deletion.spec.ts new file mode 100644 index 0000000000..551692e65a --- /dev/null +++ b/e2e/tests/sync/supersync-account-deletion.spec.ts @@ -0,0 +1,190 @@ +import { test as base, expect } from '@playwright/test'; +import { + createTestUser, + getSuperSyncConfig, + createSimulatedClient, + closeClient, + deleteTestUser, + waitForTask, + isServerHealthy, + type SimulatedE2EClient, +} from '../../utils/supersync-helpers'; + +/** + * SuperSync Account Deletion E2E Tests + * + * These tests verify client behavior when: + * - User account is deleted on the server + * - Client tries to sync after account deletion + * + * Prerequisites: + * - super-sync-server running on localhost:1901 with TEST_MODE=true + * - Frontend running on localhost:4242 + * + * Run with: npm run e2e:supersync:file e2e/tests/sync/supersync-account-deletion.spec.ts + */ + +const generateTestRunId = (workerIndex: number): string => { + return `${Date.now()}-${workerIndex}`; +}; + +base.describe('@supersync SuperSync Account Deletion', () => { + let serverHealthy: boolean | null = null; + + base.beforeEach(async ({}, testInfo) => { + if (serverHealthy === null) { + serverHealthy = await isServerHealthy(); + if (!serverHealthy) { + console.warn( + 'SuperSync server not healthy at http://localhost:1901 - skipping tests', + ); + } + } + testInfo.skip(!serverHealthy, 'SuperSync server not running'); + }); + + /** + * Scenario: Client detects auth error after account deletion + * + * Tests that when a user's account is deleted on the server, + * the client shows an appropriate error indicator. + * + * Actions: + * 1. Create user and client, sync successfully + * 2. Delete user account on server + * 3. Client tries to sync again + * 4. Verify client shows error (snackbar or error icon) + */ + base( + 'Client shows error after account deletion', + async ({ browser, baseURL }, testInfo) => { + testInfo.setTimeout(90000); + const testRunId = generateTestRunId(testInfo.workerIndex); + const appUrl = baseURL || 'http://localhost:4242'; + let client: SimulatedE2EClient | null = null; + + try { + // 1. Create user and set up client + const user = await createTestUser(testRunId); + const syncConfig = getSuperSyncConfig(user); + console.log(`[Account-Deletion] Created user ${user.userId}`); + + client = await createSimulatedClient(browser, appUrl, 'A', testRunId); + await client.sync.setupSuperSync(syncConfig); + + // Create a task and sync to verify everything works + const taskName = `Pre-Delete-Task-${testRunId}`; + await client.workView.addTask(taskName); + await client.sync.syncAndWait(); + await waitForTask(client.page, taskName); + console.log('[Account-Deletion] ✓ Initial sync successful'); + + // 2. Delete the user account on the server + await deleteTestUser(user.userId); + console.log(`[Account-Deletion] ✓ Deleted user ${user.userId}`); + + // 3. Try to sync again - should fail with auth error + await client.sync.triggerSync(); + + // 4. Wait for error indicator to appear + // The sync button should show error state (sync_problem icon), + // or a snackbar with error message should appear + const errorIndicator = client.page.locator( + // Error snackbar with any of these indicators + 'simple-snack-bar:has-text("Configure"), ' + + 'simple-snack-bar:has-text("error"), ' + + 'simple-snack-bar:has-text("401"), ' + + 'simple-snack-bar:has-text("403"), ' + + 'simple-snack-bar:has-text("Authentication"), ' + + // Or sync button showing error icon + '.sync-btn mat-icon:has-text("sync_problem")', + ); + + await expect(errorIndicator.first()).toBeVisible({ timeout: 20000 }); + console.log('[Account-Deletion] ✓ Client detected auth failure'); + } finally { + if (client) await closeClient(client); + } + }, + ); + + /** + * Scenario: Client can reconfigure after account deletion and re-registration + * + * Tests that after account deletion, a client can be reconfigured + * with new credentials and resume syncing. + * + * Actions: + * 1. Create user A, set up client, sync successfully + * 2. Delete user A account + * 3. Create user B (new account) + * 4. Reconfigure client with user B credentials + * 5. Verify sync works with new account + */ + base( + 'Client can reconfigure with new account after deletion', + async ({ browser, baseURL }, testInfo) => { + testInfo.setTimeout(120000); + const testRunId = generateTestRunId(testInfo.workerIndex); + const appUrl = baseURL || 'http://localhost:4242'; + let client: SimulatedE2EClient | null = null; + + try { + // 1. Create first user and sync + const userA = await createTestUser(`${testRunId}-A`); + const configA = getSuperSyncConfig(userA); + console.log(`[Reconfigure] Created user A: ${userA.userId}`); + + client = await createSimulatedClient(browser, appUrl, 'Client', testRunId); + await client.sync.setupSuperSync(configA); + + const task1 = `UserA-Task-${testRunId}`; + await client.workView.addTask(task1); + await client.sync.syncAndWait(); + console.log('[Reconfigure] ✓ User A sync successful'); + + // 2. Delete user A account + await deleteTestUser(userA.userId); + console.log('[Reconfigure] ✓ Deleted user A'); + + // 3. Create user B (new account) + const userB = await createTestUser(`${testRunId}-B`); + const configB = getSuperSyncConfig(userB); + console.log(`[Reconfigure] ✓ Created user B: ${userB.userId}`); + + // 4. Reconfigure client with user B credentials + // Open sync settings via right-click + await client.sync.syncBtn.click({ button: 'right' }); + await client.sync.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); + + // Update access token + await client.sync.accessTokenInput.clear(); + await client.sync.accessTokenInput.fill(configB.accessToken); + + // Save configuration + await client.sync.saveBtn.click(); + await client.page + .locator('mat-dialog-container') + .waitFor({ state: 'detached', timeout: 5000 }) + .catch(() => {}); + + // Wait for any dialogs to settle + await client.page.waitForTimeout(1000); + + // 5. Sync and verify it works with user B + await client.sync.syncAndWait(); + + // Create a new task with user B + const task2 = `UserB-Task-${testRunId}`; + await client.workView.addTask(task2); + await client.sync.syncAndWait(); + + // Verify task exists + await waitForTask(client.page, task2); + console.log('[Reconfigure] ✓ User B sync successful - reconfiguration complete'); + } finally { + if (client) await closeClient(client); + } + }, + ); +}); diff --git a/e2e/utils/supersync-helpers.ts b/e2e/utils/supersync-helpers.ts index cc009321f6..0b3f1e91c4 100644 --- a/e2e/utils/supersync-helpers.ts +++ b/e2e/utils/supersync-helpers.ts @@ -94,6 +94,23 @@ export const cleanupTestData = async (): Promise => { } }; +/** + * Delete a specific test user account on the SuperSync server. + * Used to test account deletion and re-registration scenarios. + * + * @param userId - The user ID to delete + */ +export const deleteTestUser = async (userId: number): Promise => { + const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/user/${userId}`, { + method: 'DELETE', + }); + + if (!response.ok && response.status !== 404) { + const text = await response.text(); + throw new Error(`Failed to delete test user: ${response.status} - ${text}`); + } +}; + /** * Check if the SuperSync server is running, healthy, AND has test mode enabled. * Tests require TEST_MODE=true on the server for the /api/test/* endpoints. diff --git a/packages/super-sync-server/src/test-routes.ts b/packages/super-sync-server/src/test-routes.ts index 9e1394d090..3eeff098f1 100644 --- a/packages/super-sync-server/src/test-routes.ts +++ b/packages/super-sync-server/src/test-routes.ts @@ -149,5 +149,47 @@ export const testRoutes = async (fastify: FastifyInstance): Promise => { }, ); + /** + * Delete a test user by userId. + * Used by E2E tests to simulate account deletion scenarios. + */ + fastify.delete<{ Params: { userId: string } }>( + '/user/:userId', + { + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' }, + }, + }, + }, + config: { + rateLimit: false, + }, + }, + async (request, reply) => { + const userId = parseInt(request.params.userId, 10); + + if (isNaN(userId)) { + return reply.status(400).send({ error: 'Invalid userId' }); + } + + try { + // CASCADE delete handles: operations, syncState, devices (via Prisma schema) + await prisma.user.delete({ where: { id: userId } }); + Logger.info(`[TEST] Deleted test user ID: ${userId}`); + return reply.send({ deleted: true, userId }); + } catch (err: unknown) { + Logger.error('[TEST] Failed to delete test user:', err); + return reply.status(404).send({ + error: 'User not found or already deleted', + message: (err as Error).message, + }); + } + }, + ); + Logger.info('[TEST] Test routes registered at /api/test/*'); }; diff --git a/src/app/pfapi/api/sync/providers/super-sync/super-sync.spec.ts b/src/app/pfapi/api/sync/providers/super-sync/super-sync.spec.ts index 533ed52709..1d192b0583 100644 --- a/src/app/pfapi/api/sync/providers/super-sync/super-sync.spec.ts +++ b/src/app/pfapi/api/sync/providers/super-sync/super-sync.spec.ts @@ -2,7 +2,7 @@ import { SuperSyncProvider } from './super-sync'; import { SuperSyncPrivateCfg } from './super-sync.model'; import { SyncProviderPrivateCfgStore } from '../../sync-provider-private-cfg-store'; import { SyncProviderId } from '../../../pfapi.const'; -import { MissingCredentialsSPError } from '../../../errors/errors'; +import { MissingCredentialsSPError, AuthFailSPError } from '../../../errors/errors'; import { SyncOperation } from '../../sync-provider.interface'; // Helper to decompress gzip Uint8Array to string @@ -479,20 +479,20 @@ describe('SuperSyncProvider', () => { }); describe('error handling', () => { - it('should include error text in API error message', async () => { + it('should include error text in API error message for non-auth errors', async () => { mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); fetchSpy.and.returnValue( Promise.resolve({ ok: false, - status: 401, - statusText: 'Unauthorized', - text: () => Promise.resolve('Invalid token'), + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve('Database connection failed'), } as Response), ); await expectAsync(provider.downloadOps(0)).toBeRejectedWithError( - 'SuperSync API error: 401 Unauthorized - Invalid token', + 'SuperSync API error: 500 Internal Server Error - Database connection failed', ); }); @@ -552,6 +552,137 @@ describe('SuperSyncProvider', () => { }); }); + describe('authentication error handling', () => { + it('should throw AuthFailSPError on 401 Unauthorized response', async () => { + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('Invalid or expired token'), + } as Response), + ); + + await expectAsync(provider.downloadOps(0)).toBeRejectedWith( + jasmine.any(AuthFailSPError), + ); + }); + + it('should throw AuthFailSPError on 403 Forbidden response', async () => { + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: () => Promise.resolve('Account deleted'), + } as Response), + ); + + await expectAsync(provider.downloadOps(0)).toBeRejectedWith( + jasmine.any(AuthFailSPError), + ); + }); + + it('should clear cached config on auth failure', async () => { + // First call succeeds - config gets cached + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + fetchSpy.and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ops: [], hasMore: false, latestSeq: 0 }), + } as Response), + ); + await provider.downloadOps(0); + expect(mockPrivateCfgStore.load).toHaveBeenCalledTimes(1); + + // Second call fails with 401 + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('Token expired'), + } as Response), + ); + + try { + await provider.downloadOps(0); + } catch (e) { + // Expected to throw AuthFailSPError + expect(e).toBeInstanceOf(AuthFailSPError); + } + + // Third call should reload config from store (cache was cleared) + fetchSpy.and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ops: [], hasMore: false, latestSeq: 0 }), + } as Response), + ); + await provider.downloadOps(0); + expect(mockPrivateCfgStore.load).toHaveBeenCalledTimes(2); + }); + + it('should throw AuthFailSPError for uploadOps on 401', async () => { + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('Account not found'), + } as Response), + ); + + await expectAsync( + provider.uploadOps([createMockOperation()], 'client-1'), + ).toBeRejectedWith(jasmine.any(AuthFailSPError)); + }); + + it('should throw AuthFailSPError for uploadSnapshot on 401', async () => { + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: () => Promise.resolve('Invalid token'), + } as Response), + ); + + await expectAsync( + provider.uploadSnapshot({}, 'client-1', 'recovery', {}, 1), + ).toBeRejectedWith(jasmine.any(AuthFailSPError)); + }); + + it('should include HTTP status code in AuthFailSPError message', async () => { + mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); + + fetchSpy.and.returnValue( + Promise.resolve({ + ok: false, + status: 403, + statusText: 'Forbidden', + text: () => Promise.resolve('Access denied'), + } as Response), + ); + + try { + await provider.downloadOps(0); + fail('Expected AuthFailSPError to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(AuthFailSPError); + expect((e as AuthFailSPError).message).toContain('403'); + } + }); + }); + describe('upload response with rejected ops', () => { it('should return response with CONFLICT_CONCURRENT rejection', async () => { mockPrivateCfgStore.load.and.returnValue(Promise.resolve(testConfig)); diff --git a/src/app/pfapi/api/sync/providers/super-sync/super-sync.ts b/src/app/pfapi/api/sync/providers/super-sync/super-sync.ts index ddb2f246d3..47cf7db5a4 100644 --- a/src/app/pfapi/api/sync/providers/super-sync/super-sync.ts +++ b/src/app/pfapi/api/sync/providers/super-sync/super-sync.ts @@ -16,7 +16,7 @@ import { } from '../../sync-provider.interface'; import { SyncProviderPrivateCfgStore } from '../../sync-provider-private-cfg-store'; import { SuperSyncPrivateCfg } from './super-sync.model'; -import { MissingCredentialsSPError } from '../../../errors/errors'; +import { MissingCredentialsSPError, AuthFailSPError } from '../../../errors/errors'; import { SyncLog } from '../../../../../core/log'; import { compressWithGzip, @@ -347,6 +347,20 @@ export class SuperSyncProvider return this._cachedServerSeqKey; } + /** + * Check HTTP response status and throw AuthFailSPError for auth failures. + * Clears cached config so next operation will reload from store. + */ + private _checkHttpStatus(status: number, body?: string): void { + if (status === 401 || status === 403) { + // Clear cached config so next operation will reload from store + // (allowing user to re-configure after auth failure) + this._cachedCfg = undefined; + this._cachedServerSeqKey = undefined; + throw new AuthFailSPError(`Authentication failed (HTTP ${status})`, body); + } + } + private async _fetchApi( cfg: SuperSyncPrivateCfg, path: string, @@ -370,6 +384,8 @@ export class SuperSyncProvider if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); + // Check for auth failure FIRST before throwing generic error + this._checkHttpStatus(response.status, errorText); throw new Error( `SuperSync API error: ${response.status} ${response.statusText} - ${errorText}`, ); @@ -407,6 +423,8 @@ export class SuperSyncProvider if (!response.ok) { const errorText = await response.text().catch(() => 'Unknown error'); + // Check for auth failure FIRST before throwing generic error + this._checkHttpStatus(response.status, errorText); throw new Error( `SuperSync API error: ${response.status} ${response.statusText} - ${errorText}`, ); @@ -457,6 +475,8 @@ export class SuperSyncProvider if (response.status < 200 || response.status >= 300) { const errorData = typeof response.data === 'string' ? response.data : JSON.stringify(response.data); + // Check for auth failure FIRST before throwing generic error + this._checkHttpStatus(response.status, errorData); throw new Error(`SuperSync API error: ${response.status} - ${errorData}`); }