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);
// Yjs collaboration WebSocket
registerYjsWebSocket(fastify);
registerYjsWebSocket(fastify, authService);
return fastify;
}
+22 -5
View File
@@ -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<void> | null = null;
export async function initYjsPersistence(): Promise<void> {
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)
}
}
});
+1
View File
@@ -86,6 +86,7 @@ const CADEditor: React.FC<CADEditorProps> = ({ projectId, token, onNavigateBack
userName: user?.name || 'Gast',
userColor: '#3498db',
enabled: true,
token: token || undefined,
});
// Project
+6 -3
View File
@@ -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);
}
+4 -3
View File
@@ -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<YjsDocument | null>(null);
const providerRef = useRef<WebSocketProvider | null>(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) => {
+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
**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 |