import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; import { assertRouteDeleteAllowed, assertRouteWritable, isDirectLocalRequest } from '../src/services/route-write-access.js'; const root = path.resolve(import.meta.dirname, '..'); function fakeDatabase(route) { return { prepare(sql) { assert.match(sql, /SELECT id, activated_at AS activatedAt\s+FROM routes WHERE id = \?/i); return { get(routeId) { if (!route || Number(route.id) !== Number(routeId)) return undefined; return route; } }; } }; } function request(remoteAddress, headers = {}) { return { headers, socket: { remoteAddress } }; } function assertHttpError(expectedStatus, expectedMessage) { return error => { assert.equal(error.status, expectedStatus); assert.match(error.message, expectedMessage); return true; }; } test('nicht freigegebene Strecken bleiben schreibbar', () => { const db = fakeDatabase({ id: 7, activatedAt: null }); assert.deepEqual(assertRouteWritable(db, 7), { id: 7, activatedAt: null }); }); test('freigegebene Strecken blockieren normale Schreibzugriffe', () => { const db = fakeDatabase({ id: 7, activatedAt: '2026-09-29 10:00:00' }); assert.throws( () => assertRouteWritable(db, 7), assertHttpError(409, /nicht mehr verändert/i) ); }); test('die Freigabeprüfung meldet unbekannte Strecken als nicht gefunden', () => { const db = fakeDatabase(null); assert.throws( () => assertRouteWritable(db, 999), assertHttpError(404, /nicht gefunden/i) ); }); test('direkte Loopback-Aufrufe werden als lokal erkannt', () => { assert.equal(isDirectLocalRequest(request('127.0.0.1')), true); assert.equal(isDirectLocalRequest(request('127.0.0.42')), true); assert.equal(isDirectLocalRequest(request('::1')), true); assert.equal(isDirectLocalRequest(request('::ffff:127.0.0.1')), true); }); test('externe und weitergeleitete Aufrufe gelten nicht als direkt lokal', () => { assert.equal(isDirectLocalRequest(request('192.0.2.10')), false); assert.equal(isDirectLocalRequest(request('127.0.0.1', { 'x-forwarded-for': '198.51.100.25' })), false); assert.equal(isDirectLocalRequest(request('127.0.0.1', { 'x-real-ip': '198.51.100.25' })), false); assert.equal(isDirectLocalRequest(request('::1', { forwarded: 'for=198.51.100.25;proto=https' })), false); }); test('DELETE bleibt für nicht freigegebene Strecken auch remote zulässig', () => { const db = fakeDatabase({ id: 7, activatedAt: null }); assert.deepEqual( assertRouteDeleteAllowed(db, 7, request('192.0.2.10')), { id: 7, activatedAt: null } ); }); test('DELETE einer freigegebenen Strecke ist nur direkt lokal zulässig', () => { const db = fakeDatabase({ id: 7, activatedAt: '2026-09-29 10:00:00' }); assert.deepEqual( assertRouteDeleteAllowed(db, 7, request('127.0.0.1')), { id: 7, activatedAt: '2026-09-29 10:00:00' } ); assert.throws( () => assertRouteDeleteAllowed(db, 7, request('192.0.2.10')), assertHttpError(403, /direkten lokalen Serveraufruf/i) ); assert.throws( () => assertRouteDeleteAllowed(db, 7, request('127.0.0.1', { 'x-forwarded-for': '198.51.100.25' })), assertHttpError(403, /direkten lokalen Serveraufruf/i) ); }); test('alle vorhandenen Schreibpfade verwenden die zentrale Freigabeprüfung', async () => { const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8'); const writablePatterns = [ /api\.put\('\/routes\/:id',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, /api\.post\('\/routes\/:id\/append',\s*requireWritableRoute\('id'\),\s*upload\.single\('gpx'\)/, /api\.post\('\/routes\/:id\/pois',\s*requireWritableRoute\('id'\),\s*upload\.none\(\)/, /api\.put\('\/routes\/:routeId\/pois\/:poiId',\s*requireWritableRoute\('routeId'\),\s*upload\.none\(\)/, /api\.post\('\/routes\/:routeId\/pois\/:poiId\/pictures',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, /api\.put\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('picture'\)/, /api\.post\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, /api\.put\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireWritableRoute\('routeId'\),\s*upload\.single\('audio'\)/, /api\.post\('\/routes\/:id\/restore',\s*requireWritableRoute\('id'\)/ ]; for (const pattern of writablePatterns) assert.match(api, pattern); }); test('Route-, POI-, Bild- und Audio-DELETE verwenden die lokale Freigabeausnahme', async () => { const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8'); const deletePatterns = [ /api\.delete\('\/routes\/:id',\s*requireRouteDeleteAllowed\('id'\)/, /api\.delete\('\/routes\/:routeId\/pois\/:poiId',\s*requireRouteDeleteAllowed\('routeId'\)/, /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/pictures\/:pictureId',\s*requireRouteDeleteAllowed\('routeId'\)/, /api\.delete\('\/routes\/:routeId\/pois\/:poiId\/audio',\s*requireRouteDeleteAllowed\('routeId'\)/ ]; for (const pattern of deletePatterns) assert.match(api, pattern); }); test('nginx kennzeichnet weitergeleitete Internet-Aufrufe für die Local-DELETE-Prüfung', async () => { const nginx = await fs.readFile(path.join(root, 'nginx/wegwichtel.conf'), 'utf8'); assert.match(nginx, /proxy_pass\s+http:\/\/127\.0\.0\.1:47145;/); assert.match(nginx, /proxy_set_header\s+X-Real-IP\s+\$remote_addr;/); assert.match(nginx, /proxy_set_header\s+X-Forwarded-For\s+\$proxy_add_x_forwarded_for;/); });