mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-08-01 04:01:01 +00:00
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:
parent
4e0f5e8999
commit
4f2dbcdaa7
5 changed files with 407 additions and 7 deletions
190
e2e/tests/sync/supersync-account-deletion.spec.ts
Normal file
190
e2e/tests/sync/supersync-account-deletion.spec.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/*');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue