167 lines
5.6 KiB
JavaScript
167 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;
|
|
};
|
|
}
|
|
|
|
function routeDeclaration(source, method, endpoint) {
|
|
const marker = `api.${method}('${endpoint}'`;
|
|
const start = source.indexOf(marker);
|
|
assert.notEqual(start, -1, `API route missing: ${method.toUpperCase()} ${endpoint}`);
|
|
const end = source.indexOf('\n', start);
|
|
return source.slice(start, end === -1 ? source.length : end);
|
|
}
|
|
|
|
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 mutierenden API-Pfade prüfen den Freigabestatus vor Upload oder Handler', async () => {
|
|
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
|
const writableRoutes = [
|
|
['put', '/routes/:id', 'id'],
|
|
['post', '/routes/:id/append', 'id'],
|
|
['post', '/routes/:id/pois', 'id'],
|
|
['put', '/routes/:routeId/pois/:poiId', 'routeId'],
|
|
['post', '/routes/:routeId/pois/:poiId/pictures', 'routeId'],
|
|
['put', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'],
|
|
['post', '/routes/:routeId/pois/:poiId/audio', 'routeId'],
|
|
['put', '/routes/:routeId/pois/:poiId/audio', 'routeId'],
|
|
['post', '/routes/:id/restore', 'id']
|
|
];
|
|
|
|
for (const [method, endpoint, parameter] of writableRoutes) {
|
|
const declaration = routeDeclaration(api, method, endpoint);
|
|
const guard = `requireWritableRoute('${parameter}')`;
|
|
assert.ok(declaration.includes(guard), `${method.toUpperCase()} ${endpoint} has no release-write guard`);
|
|
|
|
const uploadPosition = declaration.indexOf('upload.');
|
|
if (uploadPosition >= 0) {
|
|
assert.ok(
|
|
declaration.indexOf(guard) < uploadPosition,
|
|
`${method.toUpperCase()} ${endpoint} must check writability before processing uploads`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Route-, Streckenpunkt-, Bild- und Audio-DELETE verwenden die lokale Freigabeausnahme', async () => {
|
|
const api = await fs.readFile(path.join(root, 'src/routes/api.js'), 'utf8');
|
|
const deleteRoutes = [
|
|
['delete', '/routes/:id', 'id'],
|
|
['delete', '/routes/:routeId/pois/:poiId', 'routeId'],
|
|
['delete', '/routes/:routeId/pois/:poiId/pictures/:pictureId', 'routeId'],
|
|
['delete', '/routes/:routeId/pois/:poiId/audio', 'routeId']
|
|
];
|
|
|
|
for (const [method, endpoint, parameter] of deleteRoutes) {
|
|
const declaration = routeDeclaration(api, method, endpoint);
|
|
assert.ok(
|
|
declaration.includes(`requireRouteDeleteAllowed('${parameter}')`),
|
|
`${method.toUpperCase()} ${endpoint} has no local-delete guard`
|
|
);
|
|
}
|
|
});
|