fix(backend+frontend): WebSocket auth, Yjs persistence fix, keep docs in memory (Issues #2, #4, #5)

This commit is contained in:
2026-06-29 23:33:58 +02:00
parent 2e9dfdfec0
commit cf62a777d2
6 changed files with 99 additions and 159 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ export async function createServer(opts: ServerOptions) {
registerShareRoutes(fastify, opts.db, authService); registerShareRoutes(fastify, opts.db, authService);
// Yjs collaboration WebSocket // Yjs collaboration WebSocket
registerYjsWebSocket(fastify); registerYjsWebSocket(fastify, authService);
return fastify; return fastify;
} }
+22 -5
View File
@@ -5,6 +5,7 @@ import * as Y from 'yjs';
import { LeveldbPersistence } from 'y-leveldb'; import { LeveldbPersistence } from 'y-leveldb';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket'; import type { WebSocket } from '@fastify/websocket';
import type { AuthService } from '../auth/AuthService.js';
const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents'; const PERSISTENCE_DIR = process.env.YJS_PERSISTENCE_DIR || '/tmp/yjs-documents';
@@ -18,6 +19,12 @@ let persistenceReady: Promise<void> | null = null;
export async function initYjsPersistence(): Promise<void> { export async function initYjsPersistence(): Promise<void> {
try { try {
persistence = new LeveldbPersistence(PERSISTENCE_DIR); 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}`); console.log(`Yjs persistence initialized at ${PERSISTENCE_DIR}`);
} catch (err) { } catch (err) {
console.warn('Yjs persistence failed to init (non-fatal):', 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) => { fastify.get('/ws/collab/:docName', { websocket: true }, async (socket: WebSocket, request: any) => {
const docName = request.params.docName as string; const docName = request.params.docName as string;
if (!docName) { if (!docName) {
@@ -88,6 +95,18 @@ export function registerYjsWebSocket(fastify: FastifyInstance): void {
return; 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); const doc = await getOrCreateDoc(docName);
// Track connection // 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', () => { socket.on('close', () => {
const conns = connections.get(docName); const conns = connections.get(docName);
if (conns) { if (conns) {
conns.delete(socket); conns.delete(socket);
if (conns.size === 0) { if (conns.size === 0) {
connections.delete(docName); connections.delete(docName);
// Optionally keep doc in memory for a while // Keep doc in memory for reconnection (persistence handles durability)
// For now, remove it to free memory
docs.delete(docName);
} }
} }
}); });
+1
View File
@@ -86,6 +86,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
userName: user?.name || 'Gast', userName: user?.name || 'Gast',
userColor: '#3498db', userColor: '#3498db',
enabled: true, enabled: true,
token: token || undefined,
}); });
// Project // Project
+6 -3
View File
@@ -27,6 +27,7 @@ export class WebSocketProvider {
private reconnectDelay = 1000; private reconnectDelay = 1000;
private maxReconnectDelay = 30000; private maxReconnectDelay = 30000;
private shouldReconnect = true; private shouldReconnect = true;
private authToken: string | undefined;
constructor(opts: WebSocketProviderOptions) { constructor(opts: WebSocketProviderOptions) {
this.url = opts.url; this.url = opts.url;
@@ -36,15 +37,17 @@ export class WebSocketProvider {
this.onSync = opts.onSync; this.onSync = opts.onSync;
} }
connect(): void { connect(token?: string): void {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return; return;
} }
this.authToken = token;
this.shouldReconnect = true; this.shouldReconnect = true;
this.setStatus('connecting'); 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 { try {
this.ws = new WebSocket(fullUrl); this.ws = new WebSocket(fullUrl);
} catch (err) { } catch (err) {
@@ -130,7 +133,7 @@ export class WebSocketProvider {
if (this.reconnectTimer) return; if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null; this.reconnectTimer = null;
this.connect(); this.connect(this.authToken);
}, this.reconnectDelay); }, this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
} }
+4 -3
View File
@@ -16,6 +16,7 @@ export interface UseYjsBindingOptions {
userName: string; userName: string;
userColor: string; userColor: string;
enabled?: boolean; enabled?: boolean;
token?: string;
} }
export interface UseYjsBindingResult { export interface UseYjsBindingResult {
@@ -44,7 +45,7 @@ export interface UseYjsBindingResult {
const DEFAULT_WS_URL = `wss://${window.location.host}`; const DEFAULT_WS_URL = `wss://${window.location.host}`;
export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult { 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<YjsDocument | null>(null); const yjsDocRef = useRef<YjsDocument | null>(null);
const providerRef = useRef<WebSocketProvider | null>(null); const providerRef = useRef<WebSocketProvider | null>(null);
@@ -92,7 +93,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
const unbindLocal = provider.bindLocalUpdates(); const unbindLocal = provider.bindLocalUpdates();
// Connect // Connect
provider.connect(); provider.connect(token);
syncState(); syncState();
// Cleanup // Cleanup
@@ -107,7 +108,7 @@ export function useYjsBinding(opts: UseYjsBindingOptions): UseYjsBindingResult {
providerRef.current = null; providerRef.current = null;
awarenessRef.current = null; awarenessRef.current = null;
}; };
}, [docName, wsUrl, userId, userName, userColor, enabled]); }, [docName, wsUrl, userId, userName, userColor, enabled, token]);
// Mutators // Mutators
const setElement = useCallback((el: CADElement) => { const setElement = useCallback((el: CADElement) => {
+65 -147
View File
@@ -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 **Date**: 2026-06-29
**Status:** ✅ Alle Tests grün **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) npm run build
Tests 31 passed (31) → Exit 0 (tsc + cp schema.sql)
Duration 2.92s
``` ```
### Test-Dateien ### Frontend TypeScript Check
#### 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
``` ```
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) npm run build
Tests 84 passed (84) → Exit 0 (tsc + vite build, 341 modules transformed)
Duration 2.40s
``` ```
### Test-Dateien ## Smoke Tests
#### tests/SpatialIndex.test.ts (13 Tests) ### Server Startup
- ✅ insert & search (4 tests) ```
- Find inserted element within bounding box PORT=8082 node dist/index.js
- Not find element outside viewport → Yjs persistence initialized at /tmp/yjs-documents
- Find multiple elements within viewport → Server listening at http://0.0.0.0:8082
- Handle elements at origin → Web CAD Backend running on http://0.0.0.0:8082
- ✅ 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
#### tests/LayerManager.test.ts (39 Tests) ### Health Endpoint (Test #6)
- ✅ addLayer & getLayer (4 tests) ```
- ✅ removeLayer (3 tests) curl http://localhost:8082/api/health
- ✅ getLayers (2 tests) → HTTP 200: {"status":"ok","timestamp":"2026-06-29T21:28:52.696Z"}
- ✅ 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)
#### tests/HistoryManager.test.ts (32 Tests) ### WS Without Token → Rejected (Test #4)
- ✅ initialize (2 tests) ```
- ✅ pushSnapshot (3 tests) python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc')
- ✅ undo (4 tests) → Connection opened (handshake)
- ✅ redo (4 tests) → Closed: code=4001, reason=Authentication required
- ✅ 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)
## 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 ### WS With Valid Token → Accepted (Test #5)
- In-memory SQLite (`:memory:`) wird via `SqliteAdapter` initialisiert ```
- Fastify `app.inject()` simuliert HTTP-Requests ohne echte Netzwerkverbindung python3 websockets.connect('ws://localhost:8082/ws/collab/test-doc?token=8cf8a5ed-...')
- Auth-Flow vollständig getestet: Register → Login → Me → Password Change → Logout → Connection opened (handshake)
- Project CRUD vollständig getestet: Create → List → Get → Update → Delete → Connected! Received 30 bytes (initial state sync)
- Health Endpoint gibt `{ status: 'ok', timestamp: ISO-String }` zurück ```
### Frontend ### Login/Register Still Works (Test #7)
- SpatialIndex: rbush-basierte räumliche Suche mit insert/search/remove/clear/bulkInsert ```
- LayerManager: Layer-Verwaltung mit add/remove/toggle/tree/filter/duplicate curl -X POST /api/auth/register -d {"email":"smoketest@test.de","password":"test123","name":"Smoke Test"}
- HistoryManager: Undo/Redo-Stack mit initialize/push/undo/redo/jumpTo/subscribe/maxStackSize → 200: {"user":{"id":"user-1782768734336",...},"session":{"token":"8cf8a5ed-..."}}
- jsdom environment für DOM-abhängige Tests aktiviert ```
## Coverage ## Summary
### Backend | Test | Result |
- Auth CRUD: Register, Login, Logout, Me, Password-Change → alle Endpoints getestet |------|--------|
- Project CRUD: List, Get, Create, Update, Delete → alle Endpoints getestet | Backend tsc --noEmit | ✅ Pass |
- Health: GET /api/health → getestet | Frontend tsc --noEmit | ✅ Pass |
| Backend build | ✅ Pass |
### Frontend | Frontend build | ✅ Pass |
- SpatialIndex: insert, bulkInsert, search, remove, clear → alle öffentlichen Methoden | Server starts | ✅ Pass |
- LayerManager: alle 18 öffentlichen Methoden getestet | Health endpoint 200 | ✅ Pass |
- HistoryManager: initialize, pushSnapshot, undo, redo, canUndo, canRedo, getHistory, jumpTo, clear, subscribe, getUndoCount, getRedoCount → alle öffentlichen Methoden | WS no token → 4001 | ✅ Pass |
| WS invalid token → 4001 | ✅ Pass |
## Verbotene Patterns Check | WS valid token → accepted | ✅ Pass |
- ❌ Keine echten Netzwerk-Verbindungen → ✅ Nur `app.inject()` | Register/login works | ✅ Pass |
- ❌ 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