allow update for uploaded routes and implement creator id for routes
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Successful in 21s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m3s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-03 16:43:08 +02:00
parent 4b8bdcad5f
commit 0db91660b3
20 changed files with 423 additions and 91 deletions
+64 -5
View File
@@ -188,6 +188,7 @@ function submitEvent() {
}
function buildEditorContext() {
const controls = { failPicture: false, approved: false, recording: null };
const ids = [
'editor-add-poi',
'editor-button',
@@ -254,13 +255,22 @@ function buildEditorContext() {
async function fetch(url, options = {}) {
const method = options.method || 'GET';
calls.push({ body: options.body, method, url });
if (controls.failPicture && url.endsWith('/pictures/24') && method === 'PUT') {
controls.failPicture = false;
return jsonResponse({ message: 'Testfehler' }, false, 500);
}
if (String(url).startsWith('data:')) {
return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
}
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)(?:\/24)?$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ id: 24 }, true, 201);
if (url === '/api/routes/22' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] });
if (url === '/api/routes/22' && method === 'PUT') return jsonResponse({ id: 22 });
if (url === '/api/routes/22/pois/23' && method === 'PUT') return jsonResponse({ id: 23 });
if (/^\/api\/routes\/22\/pois\/23(?:\/audio|\/pictures\/24)?$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
@@ -280,6 +290,7 @@ function buildEditorContext() {
const mediaBlobs = new Map();
const storedMediaCalls = [];
const LocalRoutes = {
creatorId: () => "a".repeat(64),
createPoiId: () => 9001,
createRouteId: () => 'local-new',
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
@@ -354,17 +365,25 @@ function buildEditorContext() {
Wegwichtel: {
App: { currentPosition: () => ({ lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }) },
Config: { apiBase: '/api/' },
RouteRecorder: {
beginEditing(id) { controls.recording = id; },
endEditing() { controls.recording = null; },
renderPoints() {},
editGpx: gpx => gpx,
get routeId() { return controls.recording; },
get position() { return { lat: 50.123456789, lon: 10.987654321, accuracy: 4.321 }; }
},
LocalRoutes
}
};
context.globalThis = context;
return { calls, context, elements, packages, saves, storedMediaCalls };
return { calls, context, elements, packages, saves, storedMediaCalls, controls };
}
test('public editor workflow saves new route and GPX Station locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext();
const { calls, context, elements, packages, saves, controls } = buildEditorContext();
await loadI18n(context);
@@ -390,6 +409,7 @@ test('public editor workflow saves new route and GPX Station locally without POS
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
assert.equal(controls.recording, 'local-new');
assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, true);
@@ -452,9 +472,35 @@ test('public editor workflow saves new route and GPX Station locally without POS
local = packages.get('local-new');
assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, true);
assert.equal(elements.get('editor-route-upload').textContent, 'Schon zur Prüfung gesendet');
assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-upload').textContent, 'Erneut zur Prüfung senden');
assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.');
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois').length, 1);
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures').length, 1);
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22'));
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22/pois/23/pictures/24'));
controls.failPicture = true;
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(packages.get('local-new').pois[0].serverPictureId, undefined);
assert.equal(packages.get('local-new').pois[0].images[0].serverPictureId, 24);
assert.equal(packages.get('local-new').route.serverRouteId, '22');
assert.equal(elements.get('editor-route-upload').disabled, false);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
controls.approved = true;
const writesBeforeApproval = calls.filter(call => call.method !== 'GET').length;
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.equal(calls.filter(call => call.method !== 'GET').length, writesBeforeApproval);
controls.approved = false;
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId);
@@ -481,15 +527,28 @@ test('public editor workflow saves new route and GPX Station locally without POS
await tick();
local = packages.get('local-new');
assert.equal(local.pois[0].audio, null);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23/pictures/24'));
const currentList = elements.get('editor-poi-list');
const currentItem = currentList.children.find(child => child.dataset.poiId);
currentList.dispatchEvent(pointerEvent(currentItem.children[1].children[2]));
assert.equal(elements.get('editor-poi-delete').hidden, false);
elements.get('editor-poi-delete').dispatchEvent(pointerEvent());
await tick();
await tick();
local = packages.get('local-new');
assert.equal(local.pois.length, 0);
elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 10; i += 1) await tick();
assert.ok(calls.some(call => call.method === 'DELETE' && call.url === '/api/routes/22/pois/23'));
context.Wegwichtel.EditorUi.close();
assert.equal(controls.recording, null);
assert.equal(elements.get('editor-dialog').open, false);
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
});
+33 -3
View File
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { webcrypto } from 'node:crypto';
const root = path.resolve(import.meta.dirname, '..');
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
@@ -322,7 +323,7 @@ class FakeFileReader extends EventTarget {
async function loadLocalRoutes(options = {}) {
const sourcePath = path.join(root, 'public/js/local-routes.js');
const source = await fs.readFile(sourcePath, 'utf8');
const localStorage = new FakeStorage();
const localStorage = options.localStorage || new FakeStorage();
const indexedDB = new FakeIndexedDB();
const document = new EventTarget();
const events = [];
@@ -336,7 +337,7 @@ async function loadLocalRoutes(options = {}) {
EventTarget,
FileReader: FakeFileReader,
XMLSerializer: FakeXMLSerializer,
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
crypto: options.crypto || { randomUUID: () => '11111111-2222-4333-8444-555555555555', getRandomValues: bytes => webcrypto.getRandomValues(bytes) },
document,
globalThis: null,
indexedDB,
@@ -348,7 +349,7 @@ async function loadLocalRoutes(options = {}) {
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage, context };
}
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
@@ -700,3 +701,32 @@ test('multiple Stationen with large local media keep Local Storage metadata smal
assert.doesNotMatch(metadata, /data:image/);
assert.equal(api.get(routeId).pois.length, 2);
});
test('256-bit creator identity survives reload, route deletion and requires explicit confirmation to reset', async () => {
const first = await loadLocalRoutes();
const identity = first.api.creatorId();
assert.match(identity, /^[a-f0-9]{64}$/);
const second = await loadLocalRoutes({ localStorage: first.localStorage });
assert.equal(second.api.creatorId(), identity);
const saved = second.api.save({ id: 'identity-test', route: { name: 'Test' }, pois: [] });
assert.equal(saved.route.creator_id, identity);
await second.api.remove('identity-test');
assert.equal(second.api.creatorId(), identity);
second.context.confirm = () => false;
assert.equal(second.api.forgetCreatorId(), false);
assert.equal(second.api.creatorId(), identity);
second.context.confirm = () => true;
assert.equal(second.api.forgetCreatorId(), true);
assert.notEqual(second.api.creatorId(), identity);
const other = await loadLocalRoutes();
assert.notEqual(other.api.creatorId(), identity);
});
test('creator identity never falls back to insecure randomness or replaces corrupt stored identity', async () => {
const { api, localStorage } = await loadLocalRoutes({ crypto: {} });
assert.throws(() => api.creatorId(), /Gerätekennung/);
localStorage.setItem('wegwichtel.creator-id.v1', 'broken');
assert.throws(() => api.creatorId(), /Gerätekennung/);
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
});
+31
View File
@@ -214,6 +214,37 @@ assert result['ok'] is True
assert.doesNotMatch(downloadedGpx.toString(), /<trkpt /);
assert.deepEqual(parseGpx(downloadedGpx.toString()).points, parseGpx(gpx.toString()).points);
assert.equal(route.creator_id, null);
const creatorId = 'a'.repeat(64);
const { body: assigned } = await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: creatorId })
});
assert.equal(assigned.creator_id, creatorId);
await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: 'b'.repeat(64) })
}, 409);
await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ creator_id: 'bad' })
}, 400);
const { body: preserved } = await requestJson(`${baseUrl}/api/routes/${route.id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: 'Ersteller bleibt erhalten' })
});
assert.equal(preserved.creator_id, creatorId);
const createWithIdentity = new FormData();
createWithIdentity.append('name', 'Ersteller-Test');
createWithIdentity.append('creator_id', creatorId);
createWithIdentity.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
const { body: ownedRoute } = await requestJson(`${baseUrl}/api/routes`, { method: 'POST', body: createWithIdentity }, 201);
assert.equal(ownedRoute.creator_id, creatorId);
const invalidIdentity = new FormData();
invalidIdentity.append('creator_id', 'invalid');
invalidIdentity.append('gpx', new Blob([gpx], { type: 'application/gpx+xml' }), 'route.gpx');
await requestJson(`${baseUrl}/api/routes`, { method: 'POST', body: invalidIdentity }, 400);
const { body: pendingPoi } = await requestJson(`${baseUrl}/api/routes/${route.id}/pois`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Ohne Empfang', lat: null, lon: null })
+8
View File
@@ -57,6 +57,14 @@ class ModerationTests(unittest.TestCase):
self.assertIsNone(self.activation())
self.assertIsNone(self.activation(2))
def test_creator_id_is_shown_in_listing(self):
identity = 'a' * 64
with sqlite3.connect(self.path) as database:
database.execute('UPDATE routes SET creator_id=? WHERE id=1', (identity,))
self.assertEqual(self.run_cli([]), 0)
self.assertIn('Ersteller-ID', self.out.getvalue())
self.assertIn(identity, self.out.getvalue())
def test_aliases(self):
for flag in ["--enable", "--enanble"]:
self.assertEqual(self.run_cli([flag, "1"]), 0)
+24 -1
View File
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
node.dispatchEvent(event);
}
test('recording persists every 30 seconds independently of the editor, pauses and finishes', async () => {
test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
@@ -103,3 +103,26 @@ test('missing recorded locations can be filled manually while newer samples rema
activate(nodes.get('editor-add-position'));
assert.equal(JSON.parse(recorder.editGpx('[]', 'Entwurf')).length, 1);
});
test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => {
const { recorder, intervals, packages } = await setup();
assert.equal(recorder.status, 'idle');
recorder.beginEditing('draft');
await tick();
assert.equal(intervals.size, 1);
recorder.pause();
recorder.beginEditing('draft');
assert.equal(recorder.status, 'paused');
recorder.endEditing();
assert.equal(recorder.status, 'idle');
assert.equal(recorder.routeId, null);
assert.equal(intervals.size, 0);
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
recorder.beginEditing('draft');
await tick();
assert.equal(recorder.status, 'recording');
const fresh = await setup();
assert.equal(fresh.recorder.status, 'idle');
assert.equal(fresh.intervals.size, 0);
});
+1
View File
@@ -15,6 +15,7 @@ const EXPECTED_SCHEMA = Object.freeze({
routes: {
id: { type: 'INTEGER', pk: 1 },
slug: { type: 'TEXT', notnull: 1 },
creator_id: { type: 'TEXT', notnull: 0 },
name: { type: 'TEXT', notnull: 1 },
status: { type: 'TEXT', notnull: 1 },
gpx_path: { type: 'TEXT', notnull: 1 },