feat(sync): handle auth errors for account deletion scenarios

When a SuperSync account is deleted, clients now properly detect
authentication failures and prompt for reconfiguration:

- Add _checkHttpStatus() helper to SuperSyncProvider that throws
  AuthFailSPError on 401/403 responses
- Clear cached credentials on auth failure to allow reconfiguration
- Add DELETE /api/test/user/:userId endpoint for E2E testing
- Add deleteTestUser() helper in supersync-helpers.ts
- Add E2E tests for account deletion and reconfiguration scenarios

The existing SyncWrapperService already handles AuthFailSPError by
showing a snackbar with "Configure" action, so no UI changes needed.
This commit is contained in:
Johannes Millan 2026-01-02 15:48:17 +01:00
parent 4e0f5e8999
commit 4f2dbcdaa7
5 changed files with 407 additions and 7 deletions

View file

@ -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);
}
},
);
});

View file

@ -94,6 +94,23 @@ export const cleanupTestData = async (): Promise<void> => {
}
};
/**
* 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<void> => {
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.

View file

@ -149,5 +149,47 @@ export const testRoutes = async (fastify: FastifyInstance): Promise<void> => {
},
);
/**
* 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/*');
};

View file

@ -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));

View file

@ -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<T>(
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}`);
}