allow update for uploaded routes and implement creator id for routes
This commit is contained in:
+64
-5
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user