mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* feat(sync): add Helm chart for SuperSync Kubernetes deployment Includes Deployment, StatefulSet (PostgreSQL), Service, Ingress, ConfigMap, Secret, HPA, PDB, NetworkPolicy, and test templates. Supports both bundled PostgreSQL and external database configurations. * feat(sync): add WebSocket push notifications for near-realtime sync Server: Fastify WebSocket plugin with connection manager, app-level heartbeat (30s), debounced notifications, and per-user routing. Client: WebSocket service with exponential backoff reconnection, WS-triggered download service, and reduced polling when connected. * fix(sync): improve WebSocket error handling and reactivity Make syncInterval$ reactive to WS connection state, fix Set/Map mutation during heartbeat iteration, add error handling to WS route handler, separate JSON parsing from message handling, detect auth failures in WS-triggered downloads, and add logging to all empty catch blocks. * fix(sync): address PR review findings for WebSocket and Helm Fix race condition in WS-triggered download pipeline by moving isSyncInProgress filter after debounce and adding guard in _downloadOps. Add logging to remaining empty catch blocks, fix missing $NODE_IP in Helm NOTES.txt, and correct inaccurate comments in values.yaml and ws-triggered-download.service.ts. * fix(sync): add rate limiting to WebSocket upgrade endpoint Limit WS connection attempts to 10 per minute per IP to prevent connection flooding, matching the rate-limit pattern used by other sync endpoints. * fix(sync): extract shared constants, fix debounce correctness, replace deprecated toPromise Extract CLIENT_ID_REGEX and MAX_CLIENT_ID_LENGTH into sync.const.ts to prevent drift between sync.routes.ts and websocket.routes.ts. Fix debounce in notifyNewOps to accumulate excluded client IDs across rapid calls from different clients, preventing self-notifications. Replace deprecated toPromise() with firstValueFrom in connectWebSocket and _sync methods. Add .catch() to reconnect path and logging to silent early returns in _downloadOps. * fix(build): restore correct package-lock.json and fix upstream lint errors * revert: restore upstream HTML formatting to match CI prettier config * fix(sync): use DownloadOutcome discriminated union in WsTriggeredDownloadService * fix(sync): narrow TokenVerificationResult before accessing userId * fix(sync): await async getProviderById in connectWebSocket Missing await caused the Promise object to be cast to SuperSyncProvider, making getWebSocketParams undefined. * feat(sync): add SEED_USERS env var to create verified users on startup For self-hosted single-user setups: set SEED_USERS=email1,email2 to create verified users on boot and log their access tokens. Skips existing users. Removes need for SMTP/magic link registration. Also fix Dockerfile to use npm install instead of npm ci for lockfile compatibility. * fix(sync): address PR review feedback for Helm chart and server Security: - Remove seed-users.ts (logged full JWT tokens to stdout) - Add fail assertions for missing jwtSecret and postgresql.password - Add smtp.user/smtp.password values fields - Add from: selector to NetworkPolicy ingress - Add egress rule for external database when postgresql.enabled=false Correctness: - Fix PostgreSQL StatefulSet indentation for non-persistent mode - Fix NOTES.txt panic on empty tls list (use len instead of index) - Restore npm ci in Dockerfile by using node:24-alpine (ships npm 11) - Add Recreate deployment strategy when using RWO PVC Operational: - Add fail guard preventing HPA maxReplicas > 1 (in-memory WS state) - Fix PDB to use maxUnavailable instead of minAvailable - Add WebSocket ingress timeout annotation examples - Add Prisma db push init container for schema migrations - Default serviceAccount.automount to false - Add Chart.yaml maintainers, home, sources metadata * feat(sync): add ALLOWED_EMAILS env var to restrict registration Supports fully qualified emails (user@example.com) and domain wildcards (*@example.com). When unset, all emails are allowed. Applied to all three registration endpoints (passkey options, passkey verify, magic link). * fix(sync): exempt health endpoint from rate limiting Kubernetes liveness/readiness probes hit /health every 5-15s, exhausting the global rate limit (100 req/15min) and causing 429 responses that trigger container restarts. * fix(sync): harden WebSocket, Helm chart, and sync services from multi-agent review - Pin prisma@5.22.0 in init container and use migrate deploy instead of db push - Add per-user WebSocket connection limit (max 10) to prevent resource exhaustion - Add replicaCount > 1 fail guard in deployment template (in-memory WS state) - Set maxPayload: 1024 on WebSocket plugin (only pong messages expected) - Remove premature IN_SYNC status from download-only WS-triggered sync - Fix double removeConnection on error+close events (let close handle cleanup) - DRY debounce logic in notifyNewOps and store latestSeq on pending entry - Remove dead fromClientId from NewOpsNotification and WsMessage interfaces - Add takeUntilDestroyed to _wsProviderCleanup subscription - Simplify email-allowlist.ts to eager init (eliminate mutable state) - Guard connectWebSocket at call site for non-SuperSync providers - Add distinctUntilChanged to syncInterval$ to prevent unnecessary resets - Add container-level securityContext to PostgreSQL StatefulSet - Fix HPA maxReplicas default to 1 (matches single-replica constraint) * fix(sync): restore migration in Dockerfile CMD for non-Helm Docker users Helm users get migration via init container (runs first, CMD becomes no-op). Docker-compose/plain Docker users get automatic migration back on startup. * fix(boards): add missing drag delay for touch and extract shared signal Add cdkDragStartDelay to board-panel drag items, which was missing entirely. Extract the repeated `isTouchActive() ? DRAG_DELAY_FOR_TOUCH : 0` expression into a shared `dragDelayForTouch` computed signal and refactor all 8 components to use it. * test(sync): add comprehensive WebSocket test coverage Add unit tests for the new WebSocket real-time sync notification pipeline: - WebSocketConnectionService (server): connection lifecycle, max-per-user limits, notification debouncing, heartbeat, graceful shutdown (13 tests) - WebSocket routes validation: token/clientId validation, close codes, error handling, validation ordering (18 tests) - SuperSyncWebSocketService (frontend): reconnection with exponential backoff, heartbeat, disconnect cleanup, URL conversion (6 tests) - WsTriggeredDownloadService: auth error handling, pipeline resilience, start() idempotency (3 tests) - SyncWrapperService: connectWebSocket guards for non-SuperSync, null params, and already-connected cases (3 tests) - E2E realtime push: verifies WS-triggered download between two clients Quality fixes: - Replace waitForTimeout with syncAndWait in E2E - Add explanatory comment for microtask flushing in sync-wrapper spec - Use getter for isSyncInProgress mock in ws-triggered-download spec --------- Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
249 lines
7.4 KiB
TypeScript
249 lines
7.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { CLIENT_ID_REGEX, MAX_CLIENT_ID_LENGTH } from '../src/sync/sync.const';
|
|
|
|
/**
|
|
* Tests the WebSocket route validation logic from websocket.routes.ts.
|
|
*
|
|
* The route handler performs three sequential validations before accepting a connection:
|
|
* 1. Token must be present
|
|
* 2. ClientId must match CLIENT_ID_REGEX and be within MAX_CLIENT_ID_LENGTH
|
|
* 3. Token must pass verifyToken() check
|
|
*
|
|
* Since Fastify's inject() does not support WebSocket upgrades, we test the
|
|
* validation logic directly — the regex, length check, and handler flow —
|
|
* rather than spinning up a real server.
|
|
*/
|
|
|
|
vi.mock('../src/logger', () => ({
|
|
Logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const mockAddConnection = vi.fn();
|
|
|
|
vi.mock('../src/sync/services/websocket-connection.service', () => ({
|
|
getWsConnectionService: () => ({
|
|
addConnection: mockAddConnection,
|
|
}),
|
|
resetWsConnectionService: vi.fn(),
|
|
}));
|
|
|
|
// Import the already-mocked verifyToken (from tests/setup.ts)
|
|
const { verifyToken } = await import('../src/auth');
|
|
|
|
/**
|
|
* Simulates the WebSocket route handler logic from websocket.routes.ts.
|
|
* This mirrors the exact validation flow without needing @fastify/websocket.
|
|
*/
|
|
async function simulateWsHandler(
|
|
query: { token?: string; clientId?: string },
|
|
socket: { close: ReturnType<typeof vi.fn> },
|
|
): Promise<'accepted' | 'rejected'> {
|
|
// Dynamic import to pick up the vi.mock above
|
|
const { getWsConnectionService } = await import(
|
|
'../src/sync/services/websocket-connection.service'
|
|
);
|
|
|
|
try {
|
|
const { token, clientId } = query;
|
|
|
|
if (!token) {
|
|
socket.close(4001, 'Missing token');
|
|
return 'rejected';
|
|
}
|
|
|
|
if (
|
|
!clientId ||
|
|
!CLIENT_ID_REGEX.test(clientId) ||
|
|
clientId.length > MAX_CLIENT_ID_LENGTH
|
|
) {
|
|
socket.close(4001, 'Invalid clientId');
|
|
return 'rejected';
|
|
}
|
|
|
|
const result = await verifyToken(token);
|
|
if (!result.valid) {
|
|
socket.close(4003, 'Invalid token');
|
|
return 'rejected';
|
|
}
|
|
|
|
const wsService = getWsConnectionService();
|
|
wsService.addConnection(result.userId, clientId, socket as any);
|
|
return 'accepted';
|
|
} catch {
|
|
try {
|
|
socket.close(1011, 'Internal error');
|
|
} catch {
|
|
// ignore close error
|
|
}
|
|
return 'rejected';
|
|
}
|
|
}
|
|
|
|
describe('WebSocket Route Validation', () => {
|
|
let mockSocket: { close: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockSocket = { close: vi.fn() };
|
|
mockAddConnection.mockReset();
|
|
vi.mocked(verifyToken).mockResolvedValue({
|
|
valid: true,
|
|
userId: 1,
|
|
email: 'test@test.com',
|
|
});
|
|
});
|
|
|
|
describe('CLIENT_ID_REGEX', () => {
|
|
it('should accept alphanumeric characters', () => {
|
|
expect(CLIENT_ID_REGEX.test('abc123')).toBe(true);
|
|
});
|
|
|
|
it('should accept underscores and hyphens', () => {
|
|
expect(CLIENT_ID_REGEX.test('client_ID-123')).toBe(true);
|
|
});
|
|
|
|
it('should reject special characters', () => {
|
|
expect(CLIENT_ID_REGEX.test('invalid!@#')).toBe(false);
|
|
});
|
|
|
|
it('should reject spaces', () => {
|
|
expect(CLIENT_ID_REGEX.test('has space')).toBe(false);
|
|
});
|
|
|
|
it('should reject empty string', () => {
|
|
expect(CLIENT_ID_REGEX.test('')).toBe(false);
|
|
});
|
|
|
|
it('should reject dots', () => {
|
|
expect(CLIENT_ID_REGEX.test('client.id')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('MAX_CLIENT_ID_LENGTH', () => {
|
|
it('should be 255', () => {
|
|
expect(MAX_CLIENT_ID_LENGTH).toBe(255);
|
|
});
|
|
});
|
|
|
|
describe('handler validation flow', () => {
|
|
it('should reject when token is missing', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ clientId: 'valid_client' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Missing token');
|
|
});
|
|
|
|
it('should reject when token is empty string', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: '', clientId: 'valid_client' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Missing token');
|
|
});
|
|
|
|
it('should reject when clientId is missing', async () => {
|
|
const result = await simulateWsHandler({ token: 'some-token' }, mockSocket);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Invalid clientId');
|
|
});
|
|
|
|
it('should reject when clientId has invalid characters', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: 'some-token', clientId: 'bad!!!id' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Invalid clientId');
|
|
});
|
|
|
|
it('should reject when clientId exceeds max length', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: 'some-token', clientId: 'a'.repeat(256) },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Invalid clientId');
|
|
});
|
|
|
|
it('should accept clientId at exactly max length', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: 'good-token', clientId: 'a'.repeat(255) },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('accepted');
|
|
expect(mockSocket.close).not.toHaveBeenCalled();
|
|
expect(mockAddConnection).toHaveBeenCalledWith(1, 'a'.repeat(255), mockSocket);
|
|
});
|
|
|
|
it('should reject when verifyToken returns invalid', async () => {
|
|
vi.mocked(verifyToken).mockResolvedValue({
|
|
valid: false,
|
|
reason: 'token expired',
|
|
});
|
|
|
|
const result = await simulateWsHandler(
|
|
{ token: 'expired-token', clientId: 'client_1' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4003, 'Invalid token');
|
|
expect(verifyToken).toHaveBeenCalledWith('expired-token');
|
|
});
|
|
|
|
it('should accept valid connection and call addConnection', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: 'good-token', clientId: 'client_1' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('accepted');
|
|
expect(verifyToken).toHaveBeenCalledWith('good-token');
|
|
expect(mockAddConnection).toHaveBeenCalledWith(1, 'client_1', mockSocket);
|
|
expect(mockSocket.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should close with 1011 when verifyToken throws', async () => {
|
|
vi.mocked(verifyToken).mockRejectedValue(new Error('database down'));
|
|
|
|
const result = await simulateWsHandler(
|
|
{ token: 'some-token', clientId: 'client_1' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(1011, 'Internal error');
|
|
});
|
|
|
|
it('should validate token before clientId format', async () => {
|
|
const result = await simulateWsHandler({ clientId: 'bad!!!id' }, mockSocket);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(mockSocket.close).toHaveBeenCalledWith(4001, 'Missing token');
|
|
expect(verifyToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should validate clientId before calling verifyToken', async () => {
|
|
const result = await simulateWsHandler(
|
|
{ token: 'some-token', clientId: 'bad!!!id' },
|
|
mockSocket,
|
|
);
|
|
|
|
expect(result).toBe('rejected');
|
|
expect(verifyToken).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|