Files
Wegwichtel/test/route-write-access.test.js
T

152 lines
5.6 KiB
JavaScript

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