From cf62a777d24681e89d69160fe38460544db57e39 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 29 Jun 2026 23:33:58 +0200 Subject: [PATCH] fix(backend+frontend): WebSocket auth, Yjs persistence fix, keep docs in memory (Issues #2, #4, #5) --- backend/src/server.ts | 2 +- backend/src/websocket/yjsServer.ts | 27 +++- frontend/src/App.tsx | 1 + frontend/src/crdt/WebSocketProvider.ts | 9 +- frontend/src/crdt/useYjsBinding.ts | 7 +- test_report.md | 212 ++++++++----------------- 6 files changed, 99 insertions(+), 159 deletions(-) diff --git a/backend/src/server.ts b/backend/src/server.ts index 685ee7e..c13f691 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -102,7 +102,7 @@ export async function createServer(opts: ServerOptions) { registerShareRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket - registerYjsWebSocket(fastify); + registerYjsWebSocket(fastify, authService); return fastify; } diff --git a/backend/src/websocket/yjsServer.ts b/backend/src/websocket/yjsServer.ts index fb6b522..3bce38f 100644 --- a/backend/src/websocket/yjsServer.ts +++ b/backend/src/websocket/yjsServer.ts @@ -5,6 +5,7 @@ import * as Y from 'yjs'; import { LeveldbPersistence } from 'y-leveldb'; import type { FastifyInstance } from 'fastify'; import type { WebSocket } from '@fastify/websocket'; +import type { AuthService } from '../auth/AuthService.js'; const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents'; @@ -18,6 +19,12 @@ let persistenceReady: Promise | null = null; export async function initYjsPersistence(): Promise { try { persistence = new LeveldbPersistence(PERSISTENCE_DIR); + // Set persistenceReady so getOrCreateDoc can await it + persistenceReady = (async () => { + // Wait for LevelDB to be ready + await (persistence as any).getAllDocNames(); + })(); + await persistenceReady; console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`); } catch (err) { console.warn('Yjs persistence failed to init (non-fatal):', err); @@ -80,7 +87,7 @@ function broadcastUpdate(docName: string, update: Uint8Array, exclude?: WebSocke } } -export function registerYjsWebSocket(fastify: FastifyInstance): void { +export function registerYjsWebSocket(fastify: FastifyInstance, authService: AuthService): void { fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => { const docName = request.params.docName as string; if (!docName) { @@ -88,6 +95,18 @@ export function registerYjsWebSocket(fastify: FastifyInstance): void { return; } + // Issue #2: Authenticate WebSocket connection via query param token + const token = request.query.token as string; + if (!token) { + socket.close(4001, 'Authentication required'); + return; + } + const user = authService.getUserFromSession(token); + if (!user) { + socket.close(4001, 'Invalid or expired session'); + return; + } + const doc = await getOrCreateDoc(docName); // Track connection @@ -113,16 +132,14 @@ export function registerYjsWebSocket(fastify: FastifyInstance): void { } }); - // Clean up on disconnect + // Clean up on disconnect (Issue #5: keep doc in memory for reconnection) socket.on('close', () => { const conns = connections.get(docName); if (conns) { conns.delete(socket); if (conns.size === 0) { connections.delete(docName); - // Optionally keep doc in memory for a while - // For now, remove it to free memory - docs.delete(docName); + // Keep doc in memory for reconnection (persistence handles durability) } } }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7f9c9a5..de2882b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,6 +86,7 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack userName: user?.name || 'Gast', userColor: '#3498db', enabled: true, + token: token || undefined, }); // Project diff --git a/frontend/src/crdt/WebSocketProvider.ts b/frontend/src/crdt/WebSocketProvider.ts index 82888af..2919eab 100644 --- a/frontend/src/crdt/WebSocketProvider.ts +++ b/frontend/src/crdt/WebSocketProvider.ts @@ -27,6 +27,7 @@ export class WebSocketProvider { private reconnectDelay = 1000; private maxReconnectDelay = 30000; private shouldReconnect = true; + private authToken: string | undefined; constructor(opts: WebSocketProviderOptions) { this.url = opts.url; @@ -36,15 +37,17 @@ export class WebSocketProvider { this.onSync = opts.onSync; } - connect(): void { + connect(token?: string): void { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { return; } + this.authToken = token; this.shouldReconnect = true; this.setStatus('connecting'); - const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}`; + const tokenParam = token ? `?token=${encodeURIComponent(token)}` : ''; + const fullUrl = `${this.url}/ws/collab/${encodeURIComponent(this.docName)}${tokenParam}`; try { this.ws = new WebSocket(fullUrl); } catch (err) { @@ -130,7 +133,7 @@ export class WebSocketProvider { if (this.reconnectTimer) return; this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - this.connect(); + this.connect(this.authToken); }, this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); } diff --git a/frontend/src/crdt/useYjsBinding.ts b/frontend/src/crdt/useYjsBinding.ts index 705963c..928eb3d 100644 --- a/frontend/src/crdt/useYjsBinding.ts +++ b/frontend/src/crdt/useYjsBinding.ts @@ -16,6 +16,7 @@ export interface UseYjsBindingOptions { userName: string; userColor: string; enabled?: boolean; + token?: string; } export interface UseYjsBindingResult { @@ -44,7 +45,7 @@ export interface UseYjsBindingResult { const DEFAULT_WS_URL = `wss://${window.location.host}`; export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { - const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true } = opts; + const { docName, wsUrl = DEFAULT_WS_URL, userId, userName, userColor, enabled = true, token } = opts; const yjsDocRef = useRef(null); const providerRef = useRef(null); @@ -92,7 +93,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { const unbindLocal = provider.bindLocalUpdates(); // Connect - provider.connect(); + provider.connect(token); syncState(); // Cleanup @@ -107,7 +108,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { providerRef.current = null; awarenessRef.current = null; }; - }, [docName, wsUrl, userId, userName, userColor, enabled]); + }, [docName, wsUrl, userId, userName, userColor, enabled, token]); // Mutators const setElement = useCallback((el: CADElement) => { diff --git a/test_report.md b/test_report.md index e13ef75..71ae73e 100644 --- a/test_report.md +++ b/test_report.md @@ -1,170 +1,88 @@ -# Test Report – Phase 21: Vitest Test-Setup für web-cad-neu +# Test Report – Issues #2, #4, #5 Fixes -**Datum:** 2026-06-26 -**Status:** ✅ Alle Tests grün +**Date**: 2026-06-29 +**Task**: Fix WebSocket auth (#2), persistenceReady (#4), doc retention (#5) -## Backend Tests (31 Tests, 3 Files) +## Build Results -### Test-Command +### Backend TypeScript Check ``` -cd /a0/usr/workdir/web-cad-neu/backend && npm test +cd /a0/usr/workdir/web-cad-neu/backend && npx tsc --noEmit +→ Exit 0 (no errors) ``` -### Ergebnis +### Backend Build ``` -Test Files 3 passed (3) - Tests 31 passed (31) - Duration 2.92s +npm run build +→ Exit 0 (tsc + cp schema.sql) ``` -### Test-Dateien - -#### tests/health.test.ts (2 Tests) -- ✅ GET /api/health should return ok status -- ✅ GET /api/health should return valid ISO timestamp - -#### tests/auth.test.ts (18 Tests) -- ✅ POST /api/auth/register (3 tests) - - Register new user with 201 - - Reject duplicate registration with 409 - - Reject missing fields with 400 -- ✅ POST /api/auth/login (4 tests) - - Login with correct credentials - - Reject wrong password with 401 - - Reject non-existent email with 401 - - Reject missing fields with 400 -- ✅ GET /api/auth/me (3 tests) - - Return current user profile with valid token - - Reject without token with 401 - - Reject invalid token with 401 -- ✅ PATCH /api/auth/password (6 tests) - - Change password with correct old password - - Allow login with new password - - Reject old password after change - - Reject password change with wrong old password - - Reject password change without auth - - Reject missing password fields -- ✅ POST /api/auth/logout (2 tests) - - Logout and invalidate token - - Return 204 even without token - -#### tests/projects.test.ts (11 Tests) -- ✅ POST /api/projects (3 tests) - - Create project with 201 - - Reject without name with 400 - - Create with default values -- ✅ GET /api/projects (1 test) - - List all projects -- ✅ GET /api/projects/:id (2 tests) - - Get single project - - Return 404 for non-existent -- ✅ PATCH /api/projects/:id (3 tests) - - Update project name - - Update project description - - Return 404 for non-existent update -- ✅ DELETE /api/projects/:id (2 tests) - - Delete a project - - Return 404 for non-existent delete - -## Frontend Tests (84 Tests, 3 Files) - -### Test-Command +### Frontend TypeScript Check ``` -cd /a0/usr/workdir/web-cad-neu/frontend && npm test +cd /a0/usr/workdir/web-cad-neu/frontend && npx tsc --noEmit +→ Exit 0 (no errors) ``` -### Ergebnis +### Frontend Build ``` -Test Files 3 passed (3) - Tests 84 passed (84) - Duration 2.40s +npm run build +→ Exit 0 (tsc + vite build, 341 modules transformed) ``` -### Test-Dateien +## Smoke Tests -#### tests/SpatialIndex.test.ts (13 Tests) -- ✅ insert & search (4 tests) - - Find inserted element within bounding box - - Not find element outside viewport - - Find multiple elements within viewport - - Handle elements at origin -- ✅ bulkInsert (2 tests) - - Bulk insert and find all elements - - Work with empty array -- ✅ remove (2 tests) - - Remove element so it's no longer found - - Remove only specified element -- ✅ clear (1 test) - - Clear all elements from index -- ✅ edge cases (4 tests) - - Handle overlapping bounding boxes - - Handle zero-size elements - - Handle negative coordinates - - Search with large viewport +### Server Startup +``` +PORT=8082 node dist/index.js +→ Yjs persistence initialized at /tmp/yjs-documents +→ Server listening at http://0.0.0.0:8082 +→ Web CAD Backend running on http://0.0.0.0:8082 +``` -#### tests/LayerManager.test.ts (39 Tests) -- ✅ addLayer & getLayer (4 tests) -- ✅ removeLayer (3 tests) -- ✅ getLayers (2 tests) -- ✅ getVisibleLayers (1 test) -- ✅ setActiveLayer & getActiveLayer (2 tests) -- ✅ toggleVisibility (1 test) -- ✅ toggleLock (1 test) -- ✅ clear (1 test) -- ✅ renameLayer (2 tests) -- ✅ updateLayer (1 test) -- ✅ setParent (3 tests) -- ✅ getChildLayers & getLayerTree (3 tests) -- ✅ moveLayer (1 test) -- ✅ duplicateLayer (2 tests) -- ✅ filterLayers (5 tests) -- ✅ getDescendantIds (2 tests) -- ✅ isLocked (3 tests) -- ✅ getLayerColor (2 tests) +### Health Endpoint (Test #6) +``` +curl http://localhost:8082/api/health +→ HTTP 200: {"status":"ok","timestamp":"2026-06-29T21:28:52.696Z"} +``` -#### tests/HistoryManager.test.ts (32 Tests) -- ✅ initialize (2 tests) -- ✅ pushSnapshot (3 tests) -- ✅ undo (4 tests) -- ✅ redo (4 tests) -- ✅ canUndo & canRedo (4 tests) -- ✅ getHistory (3 tests) -- ✅ jumpTo (4 tests) -- ✅ clear (1 test) -- ✅ subscribe (2 tests) -- ✅ maxStackSize (2 tests) -- ✅ getUndoCount & getRedoCount (1 test) -- ✅ snapshot data integrity (2 tests) +### WS Without Token → Rejected (Test #4) +``` +python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc') +→ Connection opened (handshake) +→ Closed: code=4001, reason=Authentication required +``` -## Smoke-Test Beschreibung +### WS With Invalid Token → Rejected (Test #5) +``` +python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=invalid-token') +→ Connection opened (handshake) +→ Closed: code=4001, reason=Invalid or expired session +``` -### Backend -- In-memory SQLite (`:memory:`) wird via `SqliteAdapter` initialisiert -- Fastify `app.inject()` simuliert HTTP-Requests ohne echte Netzwerkverbindung -- Auth-Flow vollständig getestet: Register → Login → Me → Password Change → Logout -- Project CRUD vollständig getestet: Create → List → Get → Update → Delete -- Health Endpoint gibt `{ status: 'ok', timestamp: ISO-String }` zurück +### WS With Valid Token → Accepted (Test #5) +``` +python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=8cf8a5ed-...') +→ Connection opened (handshake) +→ Connected! Received 30 bytes (initial state sync) +``` -### Frontend -- SpatialIndex: rbush-basierte räumliche Suche mit insert/search/remove/clear/bulkInsert -- LayerManager: Layer-Verwaltung mit add/remove/toggle/tree/filter/duplicate -- HistoryManager: Undo/Redo-Stack mit initialize/push/undo/redo/jumpTo/subscribe/maxStackSize -- jsdom environment für DOM-abhängige Tests aktiviert +### Login/Register Still Works (Test #7) +``` +curl -X POST /api/auth/register -d {"email":"smoketest@test.de","password":"test123","name":"Smoke Test"} +→ 200: {"user":{"id":"user-1782768734336",...},"session":{"token":"8cf8a5ed-..."}} +``` -## Coverage +## Summary -### Backend -- Auth CRUD: Register, Login, Logout, Me, Password-Change → alle Endpoints getestet -- Project CRUD: List, Get, Create, Update, Delete → alle Endpoints getestet -- Health: GET /api/health → getestet - -### Frontend -- SpatialIndex: insert, bulkInsert, search, remove, clear → alle öffentlichen Methoden -- LayerManager: alle 18 öffentlichen Methoden getestet -- HistoryManager: initialize, pushSnapshot, undo, redo, canUndo, canRedo, getHistory, jumpTo, clear, subscribe, getUndoCount, getRedoCount → alle öffentlichen Methoden - -## Verbotene Patterns Check -- ❌ Keine echten Netzwerk-Verbindungen → ✅ Nur `app.inject()` -- ❌ Keine echten WebSocket-Verbindungen → ✅ Keine WebSocket-Tests -- ❌ Keine Datei-Schreiboperationen auf echte DB-Files → ✅ Nur `:memory:` -- ❌ Keine Mock-Libraries außer vitest built-in `vi` → ✅ Nur vitest built-ins +| Test | Result | +|------|--------| +| Backend tsc --noEmit | ✅ Pass | +| Frontend tsc --noEmit | ✅ Pass | +| Backend build | ✅ Pass | +| Frontend build | ✅ Pass | +| Server starts | ✅ Pass | +| Health endpoint 200 | ✅ Pass | +| WS no token → 4001 | ✅ Pass | +| WS invalid token → 4001 | ✅ Pass | +| WS valid token → accepted | ✅ Pass | +| Register/login works | ✅ Pass |