super-productivity/packages/super-sync-server/tests/websocket.routes.spec.ts
Thorsten Klein 7fa8f12132
feat(sync): add Helm chart and WebSocket push for SuperSync (#6971)
* 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>
2026-03-30 21:34:30 +02:00

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