group editor implemented
Sonarqube Scanner / Prepare environment (push) Successful in 6s
Sonarqube Scanner / Build and test (push) Successful in 27s
Sonarqube Scanner / Deploy to Server (push) Failing after 7s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s

This commit is contained in:
Florian Zumpe
2026-10-18 16:33:12 +02:00
parent 9c4d5f6324
commit 31768ae907
28 changed files with 2361 additions and 55 deletions
+240
View File
@@ -0,0 +1,240 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { EventEmitter } from 'node:events';
const runtime = fs.mkdtempSync(path.join(os.tmpdir(), 'wegwichtel-groups-'));
process.env.DATA_DIR = path.join(runtime, 'data');
process.env.STORAGE_DIR = path.join(runtime, 'storage');
const { config, ensureRuntimeDirectories } = await import('../src/config.js');
const { openDatabase } = await import('../src/database.js');
const { createRoute, createPoi } = await import('../src/services/routes-service.js');
const { createPicture, createAudio } = await import('../src/services/media-service.js');
const { resolveStoredFile } = await import('../src/services/storage.js');
const { createApiRouter } = await import('../src/routes/api.js');
const { notFoundHandler, errorHandler } = await import('../src/middleware/errors.js');
const { groupRouteGuard, GROUP_SYMBOLS, SESSION_COOKIE } = await import('../src/services/collaboration.js');
const express = (await import('express')).default;
const gpx = '<gpx><metadata><name>Waldweg</name></metadata><trk><trkseg><trkpt lat="50" lon="10"/><trkpt lat="50.01" lon="10.01"/></trkseg></trk></gpx>';
async function fixture(t) {
ensureRuntimeDirectories();
const db = openDatabase(path.join(config.dataDir, `${crypto.randomUUID()}.sqlite`));
const upload = { path: path.join(config.storageDir, 'uploads', `${crypto.randomUUID()}.gpx`) };
fs.writeFileSync(upload.path, gpx);
const source = createRoute(db, { name: 'Waldweg', creator_id: 'a'.repeat(64) }, upload);
const poi = createPoi(db, source.id, { title: 'Start', description: 'Blumen', lat: 50, lon: 10 });
for (const [kind, mime, extension] of [['picture', 'image/png', 'png'], ['audio', 'audio/mpeg', 'mp3']]) {
const file = { path: path.join(config.storageDir, 'uploads', crypto.randomUUID()), detectedMime: mime, detectedExtension: extension };
fs.writeFileSync(file.path, kind);
if (kind === 'picture') createPicture(db, source.id, poi.id, { caption: 'Wiese' }, file);
else createAudio(db, source.id, poi.id, file);
}
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', source.id);
const app = express();
app.use(express.json());
app.use('/api', createApiRouter(db));
app.use(notFoundHandler);
app.use(errorHandler);
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
async function request(client, endpoint, method = 'GET', body = null, status = 200, headers = {}) {
const response = await fetch(base + '/api' + endpoint, { method,
headers: { ...(client.cookie ? { Cookie: client.cookie } : {}), 'Content-Type': 'application/json', ...headers },
...(body ? { body: JSON.stringify(body) } : {}) });
const cookie = response.headers.get('set-cookie');
if (cookie) client.cookie = cookie.split(';')[0];
const value = response.headers.get('content-type')?.includes('application/json') ? await response.json() : await response.text();
assert.equal(response.status, status, JSON.stringify(value));
return { value, response };
}
t.after(async () => { await new Promise(resolve => server.close(resolve)); db.close(); fs.rmSync(config.storageDir, { recursive: true, force: true }); });
return { db, source, poi, request, base };
}
test('shared draft clones a published route and protects its contents and ownership', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {};
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201);
assert.equal(created.code.length, 8);
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value)));
assert.match(response.headers.get('set-cookie'), /HttpOnly/);
assert.match(response.headers.get('set-cookie'), /SameSite=Strict/);
assert.match(host.cookie, new RegExp(`^${SESSION_COOKIE}=`));
const route = created.group.route;
assert.notEqual(route.id, source.id);
assert.equal(route.activatedAt, null);
assert.equal(route.creator_id, 'a'.repeat(64));
assert.equal(route.pois[0].description, 'Blumen');
assert.equal(route.pois[0].images[0].caption, 'Wiese');
const oldImage = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(source.pois?.[0]?.id || 1);
const image = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').get(route.pois[0].id);
assert.notEqual(image.path, oldImage.path);
assert.equal(fs.readFileSync(resolveStoredFile(image.path), 'utf8'), 'picture');
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(route.pois[0].id);
assert.equal(fs.readFileSync(resolveStoredFile(audio.audio_path), 'utf8'), 'audio');
const list = (await request(outsider, '/routes')).value.routes;
assert.equal(list.some(value => value.id === route.id), false);
await request(outsider, `/routes/${route.id}`, 'GET', null, 401);
for (const spelling of [`0${route.id}`, `${route.id}.0`, `${route.id}e0`, `0x${route.id.toString(16)}`, `%${String(route.id).charCodeAt(0).toString(16)}`]) {
await request(outsider, `/routes/${spelling}`, 'GET', null, 401);
await request(outsider, `/routes/${spelling}`, 'PUT', { name: 'Bypass' }, 401);
}
await request(outsider, `/Routes/${route.id}`, 'GET', null, 401);
await request(outsider, route.pois[0].images[0].url.replace('/api', ''), 'GET', null, 401);
const joined = (await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'a'.repeat(64) })).value.group;
assert.equal(joined.role, 'member');
await request(guest, `/groups/${created.group.id}`, 'DELETE', null, 403);
const loaded = await request(guest, `/routes/${route.id}`);
assert.equal(loaded.response.headers.get('x-group-revision'), '0');
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 409);
await request(guest, `/routes/${route.id}`, 'PUT', { name: 'Changed' }, 200, { 'If-Match': '0' });
await request(host, `/routes/${route.id}`, 'PUT', { name: 'Lost update' }, 409, { 'If-Match': '0' });
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(route.id).name, 'Changed');
assert.equal(db.prepare('SELECT name FROM routes WHERE id = ?').get(source.id).name, 'Waldweg');
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
await request(guest, `/routes/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 403, { 'If-Match': '1' });
const renewed = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
await request(outsider, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
await request(host, `/groups/${created.group.id}`, 'DELETE');
await request(guest, `/routes/${route.id}`, 'GET', null, 403);
assert.equal((await request(guest, '/groups')).value.groups.length, 0);
await request(host, `/routes/${route.id}`);
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value;
assert.equal(reopened.group.active, true);
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
assert.equal((await request(guest, '/groups')).value.groups.length, 1);
});
test('invitation expiry, request origin, invalid IDs and expired sessions are enforced', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {};
await request(host, '/groups', 'POST', { routeId: source.id }, 400);
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' });
await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404);
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(host, `/groups/${'a'.repeat(32)}/code`, 'POST', {}, 404);
db.prepare('UPDATE collaboration_groups SET code_expires_at = 0 WHERE id = ?').run(created.group.id);
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }, 404);
await request(guest, '/groups/join', 'POST', { code: ['invalid'] }, 404);
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Cross site' }, 403,
{ Origin: 'https://other.test', 'If-Match': '0' });
db.prepare('UPDATE routes SET activated_at = ? WHERE id = ?').run('2026-10-18', created.group.route.id);
await request(guest, `/routes/${created.group.route.id}`);
await request(host, `/routes/${created.group.route.id}`, 'PUT', { name: 'Published change' }, 409, { 'If-Match': '0' });
db.prepare('UPDATE collaboration_sessions SET expires_at = 0').run();
assert.equal((await request(host, '/groups')).value.groups.length, 0);
await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 401);
});
test('overlapping writes are locked until completion and revision changes once', async t => {
const { db, source, request } = await fixture(t);
const host = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
const guard = groupRouteGuard(db);
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } };
const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
let reached = false;
guard(req, res, () => { reached = true; });
assert.equal(reached, true);
assert.throws(() => guard(req, res, () => {}), error => error.status === 409);
res.emit('finish');
assert.equal(db.prepare('SELECT revision FROM collaboration_groups').get().revision, 1);
res.emit('close');
});
test('group copying rolls back a failed media copy and rate limits invalid invitations', async t => {
const { db, source, request } = await fixture(t);
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
fs.rmSync(resolveStoredFile(image.path));
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, 1);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
const client = {};
for (let attempt = 0; attempt < 20; attempt += 1) {
await request(client, '/groups/join', 'POST', { code: [] }, 404, { 'X-Real-IP': '192.0.2.99' });
}
await request(client, '/groups/join', 'POST', { code: [] }, 429, { 'X-Real-IP': '192.0.2.99' });
});
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route, poi = route.pois[0];
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
const target = `/routes/${route.id}/pois/${poi.id}`;
await request(outsider, endpoint, 'POST', { creator_id: 'a'.repeat(64) }, 401);
await request(guest, target, 'PUT', { title: 'Not locked' }, 423, { 'If-Match': '0' });
const lock = (await request(host, endpoint, 'POST', { creator_id: 'b'.repeat(64) })).value;
const lockfile = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'a'.repeat(64));
assert.equal(fs.statSync(lockfile).mode & 0o777, 0o600);
const conflict = (await request(guest, endpoint, 'POST', {}, 423)).value;
assert.equal(conflict.details.code, 'point-locked');
await request(guest, target, 'PUT', { title: 'Spoofed creator' }, 423,
{ 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version, 'X-Creator-Id': 'a'.repeat(64) });
await request(guest, endpoint, 'POST', { replace: 'outdated' }, 423);
const takeover = (await request(guest, endpoint, 'POST', { replace: conflict.details.replace })).value;
assert.equal(JSON.parse(fs.readFileSync(lockfile)).creator_id, 'b'.repeat(64));
await request(host, target, 'PUT', { title: 'Lost edit' }, 423, { 'X-Poi-Lock': lock.token, 'If-Poi-Match': lock.version });
await request(host, endpoint, 'POST', { release: true, token: lock.token }, 423);
await request(host, `/Routes/0${route.id}/Pois/${poi.id}`, 'DELETE', {}, 423);
const saved = await request(guest, target, 'PUT', { title: 'Guest edit' }, 200,
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
assert.ok(saved.response.headers.get('x-poi-version'));
assert.notEqual(saved.response.headers.get('x-poi-version'), takeover.version);
await request(guest, target, 'PUT', { title: 'Stale edit' }, 409,
{ 'X-Poi-Lock': takeover.token, 'If-Poi-Match': takeover.version });
const fresh = (await request(guest, endpoint, 'POST', { token: takeover.token })).value;
// Same URL and same file count, but different bytes must invalidate the comparison.
const audio = db.prepare('SELECT audio_path FROM pois WHERE id = ?').get(poi.id);
fs.writeFileSync(resolveStoredFile(audio.audio_path), 'changed audio bytes');
await request(guest, `${target}/audio`, 'DELETE', null, 409,
{ 'X-Poi-Lock': fresh.token, 'If-Poi-Match': fresh.version });
const latest = (await request(guest, endpoint, 'POST', { token: fresh.token })).value;
assert.notEqual(latest.mediaVersion, fresh.mediaVersion);
const removed = await request(guest, `${target}/audio`, 'DELETE', null, 200,
{ 'X-Poi-Lock': latest.token, 'If-Poi-Match': latest.version });
assert.ok(removed.response.headers.get('x-poi-version'));
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
assert.equal(fs.existsSync(lockfile), false);
await request(guest, endpoint, 'POST', { release: true, token: latest.token });
await request(host, endpoint, 'POST', {});
await request(host, `/groups/${created.group.id}`, 'DELETE');
assert.equal(fs.existsSync(lockfile), false);
});
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => {
const { db, source, request } = await fixture(t);
const host = {}, guest = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route;
const second = createPoi(db, route.id, { title: 'Second stop' });
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
const first = route.pois[0];
const a = (await request(host, lockPath(first.id), 'POST', {})).value;
const b = (await request(guest, lockPath(second.id), 'POST', {})).value;
await request(host, `/routes/${route.id}/pois/${first.id}`, 'PUT', { title: 'A' }, 200,
{ 'X-Poi-Lock': a.token, 'If-Poi-Match': a.version, 'If-Match': '0' });
await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
{ 'X-Poi-Lock': b.token, 'If-Poi-Match': b.version, 'If-Match': '0' });
const fresh = (await request(host, lockPath(first.id), 'POST', { token: a.token })).value;
const req = { method: 'PUT', path: `/routes/${route.id}/pois/${first.id}`, headers: {
cookie: host.cookie, 'x-poi-lock': fresh.token, 'if-poi-match': fresh.version } };
const res = new EventEmitter(); res.setHeader = () => {}; res.json = () => {}; res.statusCode = 200;
groupRouteGuard(db)(req, res, () => {});
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token }, 423);
await request(host, lockPath(first.id), 'POST', { release: true, token: fresh.token }, 423);
await request(host, `/groups/${created.group.id}`, 'DELETE', {}, 423);
res.emit('close');
await request(guest, lockPath(first.id), 'POST', { replace: fresh.token });
assert.throws(() => req.validatePointWrite(), error => error.status === 423);
});
+141 -1
View File
@@ -826,7 +826,7 @@ test('editing an activated server route uses PUT and never writes activation sta
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
assert.ok(update);
const body = JSON.parse(update.body);
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.deepEqual(Object.keys(body).sort(), ['creator_id', 'description', 'name']);
assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
@@ -963,3 +963,143 @@ test('late station previews are released after the editor closes without replaci
assert.equal(released.length, 1);
assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
});
test('shared drafts use guarded server saves, preserve the creator and retain unsaved input on conflicts', async () => {
const h = buildEditorContext();
const shared = { id: 44, name: 'Gemeinsamer Waldweg', description: '', creator_id: 'b'.repeat(64), pois: [] };
let conflict = false;
let refreshed = 0;
const originalFetch = h.context.fetch;
h.context.Wegwichtel.Groups = {
headersFor: path => path.startsWith('/routes/44') ? { 'If-Match': '3' } : {},
observe() {}, refresh() { refreshed += 1; }
};
h.context.fetch = async (url, options = {}) => {
if (url === '/api/routes/44') {
h.calls.push({ url, ...options, method: options.method || 'GET' });
if (options.method === 'PUT') {
if (conflict) return { ...jsonResponse({ message: 'Conflict' }, false, 409), headers: { get: () => '44' } };
Object.assign(shared, JSON.parse(options.body));
}
return jsonResponse(shared);
}
return originalFetch(url, options);
};
await loadI18n(h.context);
const messages = path.join(root, 'public/js/user-messages.js');
vm.runInNewContext(await fs.readFile(messages, 'utf8'), h.context, { filename: messages });
const filename = path.join(root, 'public/js/editor-ui.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
await h.context.Wegwichtel.EditorUi.openGroup(shared);
assert.equal(h.controls.recording, null);
assert.ok(refreshed > 0);
h.elements.get('editor-route-name').value = 'Gemeinsam geändert';
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
const saved = h.calls.find(call => call.method === 'PUT' && call.url === '/api/routes/44');
assert.equal(saved.headers['If-Match'], '3');
assert.equal(JSON.parse(saved.body).creator_id, 'b'.repeat(64));
assert.equal(h.packages.has('44'), false);
assert.equal(shared.name, 'Gemeinsam geändert');
conflict = true;
h.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
h.elements.get('editor-route-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
assert.equal(shared.name, 'Gemeinsam geändert');
assert.equal(h.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
assert.match(h.elements.get('editor-status').textContent, /inzwischen geändert/);
});
test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
const h = buildEditorContext();
const sharedIds = [];
h.context.confirm = () => false;
h.context.Wegwichtel.Groups = {
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
async share(id) { sharedIds.push(id); }
};
await loadI18n(h.context);
const filename = path.join(root, 'public/js/editor-ui.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), h.context, { filename });
await h.context.Wegwichtel.EditorUi.open();
const list = h.elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
for (let i = 0; i < 4; i += 1) await tick();
assert.equal(h.calls.some(call => call.method === 'POST'), false);
h.context.confirm = () => true;
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
list.dispatchEvent(pointerEvent(item.children[1].children[1]));
for (let i = 0; i < 14; i += 1) await tick();
assert.deepEqual(sharedIds, ['22']);
assert.equal(h.packages.get('local-existing').route.serverRouteId, '22');
assert.ok(h.calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
});
test('shared station saves persist files before sending, retain lost-lock changes and release locks on exit', async () => {
const h = buildEditorContext();
const poi = { id: 8, title: 'Wiese', description: 'Blumen', lat: 50, lon: 10, sequence: 0, images: [], audioUrl: null };
const route = { id: 44, name: 'Gruppenweg', creator_id: 'a'.repeat(64), pois: [poi] };
const events = [], drafts = new Map();
let allowed = false;
h.context.Wegwichtel.GroupPoints = {
async begin() { events.push('lock'); return { poi }; },
async release() { events.push('release'); },
async read(id, poiId) { return drafts.get(String(poiId)); },
async remember(route, poiId, values, media) { const draft = { route, poiId, values, media }; events.push('backup'); drafts.set(String(poiId), structuredClone(draft)); return draft; },
async prepare() { events.push('prepare'); return allowed ? { poi } : false; },
async remove(id, poiId) { events.push('remove'); drafts.delete(String(poiId)); },
headersFor: () => ({ 'X-Poi-Lock': 'secret', 'If-Poi-Match': 'v1' }), observe() {},
async acceptNew() {}
};
const original = h.context.fetch;
h.context.fetch = async (url, options = {}) => {
if (url.startsWith('/api/routes/44')) {
h.calls.push({ url, ...options, method: options.method || 'GET' });
if (url === '/api/routes/44') return jsonResponse(route);
if (options.method === 'PUT') { events.push('send'); Object.assign(poi, JSON.parse(options.body)); return jsonResponse({ poi }); }
if (options.method === 'DELETE') { events.push('delete'); return jsonResponse({ deleted: true }); }
}
return original(url, options);
};
await loadI18n(h.context);
vm.runInNewContext(await fs.readFile('public/js/editor-ui.js', 'utf8'), h.context, { filename: path.resolve('public/js/editor-ui.js') });
await h.context.Wegwichtel.EditorUi.openGroup(route);
const list = h.elements.get('editor-poi-list');
list.dispatchEvent(pointerEvent(list.children[0].children[1].children[2])); await tick();
h.elements.get('editor-poi-title').value = 'Meine Blumen';
const file = new Blob(['photo'], { type: 'image/png' }); file.name = 'blume.png';
h.elements.get('editor-poi-images').files = [file];
h.elements.get('editor-poi-images').dispatchEvent(new Event('change')); await tick();
h.elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick();
assert.equal(events.includes('send'), false);
assert.equal(drafts.get('8').values.title, 'Meine Blumen');
assert.equal(drafts.get('8').media.images.length, 1);
assert.match(h.elements.get('editor-status').textContent, /auf diesem Gerät gesichert/);
// Remove the selected picture, then retry the same text edit with the lock held.
await h.context.Wegwichtel.EditorUi.openPendingPoint({ route, poiId: 8, basePoi: poi, values: poi, media: {} });
allowed = true;
h.elements.get('editor-poi-title').value = 'Gemeinsam gespeichert';
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
for (let i = 0; i < 6; i += 1) await tick();
assert.equal(events.filter(event => event === 'send').length, 1);
assert.ok(events.indexOf('backup') < events.indexOf('send'));
assert.equal(drafts.size, 0);
assert.equal(poi.title, 'Gemeinsam gespeichert');
const saved = h.calls.find(call => call.method === 'PUT');
assert.equal(saved.headers['X-Poi-Lock'], 'secret');
allowed = false;
const latestList = h.elements.get('editor-poi-list');
latestList.dispatchEvent(pointerEvent(latestList.children[0].children[1].children[3]));
for (let i = 0; i < 4; i += 1) await tick();
assert.equal(drafts.get('8').media.deletePoint, true);
assert.equal(events.includes('delete'), false);
allowed = true;
h.elements.get('editor-poi-form').dispatchEvent(submitEvent());
for (let i = 0; i < 5; i += 1) await tick();
assert.equal(events.includes('delete'), true);
assert.equal(drafts.size, 0);
h.context.Wegwichtel.EditorUi.close();
assert.equal(events.at(-1), 'release');
});
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import vm from 'node:vm';
import path from 'node:path';
import { loadI18n } from '../test-support/i18n.js';
class Element extends EventTarget {
constructor() { super(); this.children = []; this.open = false; }
appendChild(child) { this.children.push(child); }
replaceChildren(...children) { this.children = children; }
showModal() { this.open = true; }
close() { this.open = false; this.dispatchEvent(new Event('close')); }
}
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
async function harness(records = new Map()) {
const nodes = new Map(['point-compare-dialog','point-compare-content','point-compare-accept','point-compare-cancel'].map(id => [id, new Element()]));
const document = new EventTarget(); document.createElement = () => new Element(); document.getElementById = id => nodes.get(id);
const calls = [], controls = { accept: true, offline: false, locked: false, abort: false, version: 'v1', mediaVersion: 'm1' };
const poi = { id: 8, title: 'Start', description: 'Wiese', lat: 50, lon: 10, sequence: 0, triggerRadiusM: 25,
audioUrl: '/api/routes/7/pois/8/audio', images: [{ id: 1, url: '/image.png', caption: 'Wiese' }] };
const database = { createObjectStore() {}, transaction() {
const transaction = { error: new Error('quota'), objectStore() {
const operation = (kind, value) => {
const request = {};
queueMicrotask(() => {
if (controls.abort) { transaction.onabort?.(); return; }
if (kind === 'put') records.set(value.key, structuredClone(value));
if (kind === 'delete') records.delete(value);
request.result = kind === 'get' ? structuredClone(records.get(value)) : kind === 'all' ? structuredClone([...records.values()]) : value;
transaction.oncomplete();
});
return request;
};
return { get: id => operation('get', id), getAll: () => operation('all'), put: value => operation('put', value), delete: id => operation('delete', id) };
}}; return transaction;
}};
const context = { Event, File, Blob, document, indexedDB: { open() { const request = { result: database }; queueMicrotask(() => { request.onupgradeneeded(); request.onsuccess(); }); return request; } },
confirm: () => controls.accept, Wegwichtel: {},
fetch: async (url, options) => {
const body = JSON.parse(options.body); calls.push({ url, body });
if (controls.offline) throw new TypeError('offline');
let status = 200, payload;
if (body.release) payload = { released: true };
else if (controls.locked && !body.replace) { status = 423; payload = { details: { code: 'point-locked', replace: 'old-secret' } }; }
else payload = { token: 'our-secret', version: controls.version, mediaVersion: controls.mediaVersion, poi: structuredClone(poi) };
return { ok: status === 200, status, json: async () => payload };
}};
context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile('public/js/group-points.js', 'utf8'), context, { filename: path.resolve('public/js/group-points.js') });
return { api: context.Wegwichtel.GroupPoints, nodes, context, controls, calls, poi, records };
}
const media = () => ({ images: [new File(['photo'], 'blume.png', { type: 'image/png' })], audio: new File(['sound'], 'vogel.mp3'), deletions: [{ path: 'pictures/1' }] });
test('point takeover requires confirmation, preserves baseline and binds writes to the editing token', async () => {
const h = await harness();
h.controls.locked = true; h.controls.accept = false;
assert.equal(await h.api.begin(7, h.poi), null);
assert.equal(h.calls.length, 1);
h.controls.accept = true;
await h.api.begin(7, h.poi);
assert.equal(h.calls.at(-1).body.replace, 'old-secret');
assert.equal(h.api.headersFor('/routes/7/pois/8/audio')['X-Poi-Lock'], 'our-secret');
assert.equal(Object.keys(h.api.headersFor('/routes/9/pois/8')).length, 0);
h.api.observe({ headers: new Headers({ 'X-Poi-Version': 'v2', 'X-Poi-Id': '8' }) });
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2');
await h.api.release();
assert.equal(h.calls.at(-1).body.token, 'our-secret');
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
});
test('unsent text, blobs, removals and baseline survive reopening; transaction abort is never reported as saved', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media());
const next = await harness(h.records);
const draft = (await next.api.list())[0];
assert.equal(draft.baseVersion, 'v1');
assert.equal(draft.values.title, 'Mein Titel');
assert.equal(await draft.media.images[0].text(), 'photo');
assert.equal(await draft.media.audio.text(), 'sound');
const opened = await next.api.begin(7, next.poi);
assert.equal(opened.draft.values.title, 'Mein Titel');
next.controls.abort = true;
await assert.rejects(next.api.remember({ id: 7 }, 8, {}, {}));
assert.equal(h.records.size, 1);
next.controls.abort = false;
await next.api.remove(7, 8);
assert.equal((await next.api.list()).length, 0);
});
test('retry compares all fields and media, retains cancelled work and only uses the latest version after consent', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
const draft = await h.api.remember({ id: 7, name: 'Waldweg' }, 8, { ...h.poi, title: 'Mein Titel' }, media());
assert.ok(await h.api.prepare(draft));
h.controls.version = 'v2'; h.controls.mediaVersion = 'm2'; h.poi.description = 'Geändert';
const rejected = h.api.prepare(draft); await tick();
assert.equal(h.nodes.get('point-compare-dialog').open, true);
const table = h.nodes.get('point-compare-content').children[0];
assert.equal(table.children[1].children[2].textContent, 'Mein Titel');
assert.equal(table.children[2].children[3].textContent, 'Geändert');
assert.match(h.nodes.get('point-compare-content').children[2].textContent, /wurden geändert/);
h.nodes.get('point-compare-cancel').dispatchEvent(new Event('click'));
assert.equal(await rejected, false);
assert.equal((await h.api.list()).length, 1);
const accepted = h.api.prepare(draft); await tick();
h.nodes.get('point-compare-accept').dispatchEvent(new Event('click'));
assert.ok(await accepted);
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v2');
});
test('offline drafts remain editable without claiming a server lock and a late lock does not reopen a closed editor', async () => {
const h = await harness();
await h.api.begin(7, h.poi);
await h.api.remember({ id: 7 }, 8, h.poi, media());
await h.api.release(); h.controls.offline = true;
assert.ok((await h.api.begin(7, h.poi)).draft);
assert.equal(h.api.headersFor('/routes/7/pois/8')['X-Poi-Lock'], '');
await assert.rejects(h.api.prepare((await h.api.list())[0]));
h.controls.offline = false;
let resolve;
const original = h.context.fetch;
h.context.fetch = (url, options) => {
if (JSON.parse(options.body).release) return original(url, options);
return new Promise(finish => { resolve = () => finish({ ok: true, json: async () => ({ token: 'late', version: 'v1', poi: h.poi }) }); });
};
const opening = h.api.begin(7, h.poi); await tick();
await h.api.release(); resolve();
assert.equal(await opening, null);
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
});
+188
View File
@@ -0,0 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import { loadI18n } from '../test-support/i18n.js';
class Element extends EventTarget {
constructor() { super(); this.children = []; this.attributes = new Map(); this.dataset = {}; this.scrollTop = 0; this.open = false; }
appendChild(node) { this.children.push(node); return node; }
append(...nodes) { nodes.forEach(node => this.appendChild(node)); }
replaceChildren(...nodes) { this.children = nodes; }
setAttribute(name, value) { this.attributes.set(name, value); }
showModal() { this.open = true; }
close() { this.open = false; }
scrollTo({ top }) { this.scrollTop = top; this.dispatchEvent(new Event('scroll')); }
}
const tick = async () => { for (let i = 0; i < 4; i += 1) await new Promise(resolve => setTimeout(resolve, 0)); };
function pointer(node, properties = {}) {
const event = new Event('pointerup', { cancelable: true });
for (const [key, value] of Object.entries(properties)) Object.defineProperty(event, key, { value });
node.dispatchEvent(event);
}
function key(node, value) {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value }); node.dispatchEvent(event);
}
async function harness(loading = false) {
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = loading ? 'loading' : 'complete';
document.getElementById = id => nodes.get(id);
document.createElement = () => new Element();
const calls = [], edits = [];
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' };
const group = () => ({ id: 'group-id', route: { id: 22, name: 'Waldweg' }, revision: 0, role: controls.role, active: controls.active });
const context = { Event, document, Date, Map, Set, Headers, console, globalThis: null,
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
EditorUi: { async openGroup(route) { edits.push(route.id); } } },
fetch: async (url, options) => {
calls.push({ url, ...options });
if (controls.offline) throw new TypeError('Failed to fetch');
if (controls.error) return { ok: false, status: controls.error };
let value;
if (options.method === 'GET') value = { groups: [group()] };
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
else if (url.endsWith('/join')) value = { group: group() };
else value = { group: group(), code: Array(8).fill('🐦'), expiresAt: 1800000000 };
return { ok: true, json: async () => value };
}
};
context.globalThis = context;
await loadI18n(context);
for (const module of ['group-symbols', 'groups']) {
const filename = path.resolve('public/js', module + '.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
}
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups };
}
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
const h = await harness(true);
h.context.document.dispatchEvent(new Event('DOMContentLoaded'));
pointer(h.nodes.get('group-join-open'), { isPrimary: false });
assert.equal(h.nodes.get('group-dialog').open, false);
pointer(h.nodes.get('group-join-open'));
assert.equal(h.nodes.get('group-dialog').open, true);
const columns = h.nodes.get('group-wheels').children;
assert.equal(columns.length, 8);
const [up, viewport, down] = columns[0].children;
assert.equal(viewport.attributes.get('role'), 'spinbutton');
key(up, 'Enter');
assert.equal(viewport.attributes.get('aria-valuenow'), '9');
pointer(down);
assert.equal(viewport.attributes.get('aria-valuenow'), '1');
key(viewport, 'End'); key(viewport, 'ArrowUp');
assert.equal(viewport.attributes.get('aria-valuenow'), '8');
key(viewport, 'Home'); key(viewport, 'ArrowDown');
pointer(viewport.children[5]);
assert.equal(viewport.attributes.get('aria-valuetext'), 'Vogel');
viewport.scrollTop = 3 * 52; viewport.dispatchEvent(new Event('scroll'));
key(h.nodes.get('group-join'), ' ');
await tick();
const joined = h.calls.find(call => call.url.endsWith('/join'));
const body = JSON.parse(joined.body);
assert.equal(body.code.length, 8);
assert.equal(body.code[0], '🌼');
assert.equal(body.creator_id, 'a'.repeat(64));
assert.deepEqual(h.edits, [22]);
assert.equal(h.nodes.get('group-dialog').open, false);
pointer(h.nodes.get('editor-group-join'));
pointer(h.nodes.get('group-close'));
assert.equal(h.nodes.get('group-dialog').open, false);
});
test('sharing displays eight motifs and the owner can renew or close access with confirmation', async () => {
const h = await harness();
await h.groups.share(7);
assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
assert.equal(h.nodes.get('group-code').children.length, 8);
assert.equal(h.nodes.get('group-entry').hidden, true);
assert.match(h.nodes.get('group-expiry').textContent, /Code gilt/);
const shareButton = h.groups.shareButton('share-route');
assert.equal(shareButton.dataset.editorAction, 'share-route');
assert.equal(shareButton.children.length, 2);
pointer(h.nodes.get('group-edit')); await tick();
assert.deepEqual(h.edits, [22]);
let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
h.controls.confirm = false;
pointer(actions[2]); await tick();
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
h.controls.confirm = true;
pointer(actions[2]); await tick();
assert.ok(h.calls.some(call => call.url.endsWith('/code')));
actions = h.nodes.get('editor-group-list').children[0].children[1].children;
pointer(actions[1]); await tick();
assert.equal(h.edits.length, 2);
pointer(actions[3]); await tick();
assert.ok(h.calls.some(call => call.method === 'DELETE'));
h.controls.role = 'member'; await h.groups.refresh();
assert.equal(h.nodes.get('editor-group-list').children[0].children[1].children.length, 2);
});
test('code failures are localized and group list refresh never advances an editing revision', async () => {
const h = await harness();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': '3' }) });
assert.equal(h.groups.headersFor('/routes/22/pois/1')['If-Match'], '3');
await h.groups.refresh();
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
assert.equal(h.groups.headersFor('/routes/23')['If-Match'], undefined);
h.groups.observe({ headers: new Headers({ 'X-Group-Route': '22', 'X-Group-Revision': 'invalid' }) });
assert.equal(h.groups.headersFor('/routes/22')['If-Match'], '3');
pointer(h.nodes.get('group-join-open'));
for (const [status, text] of [[404, /Code stimmt/], [401, /nicht geöffnet/], [403, /nicht geöffnet/], [409, /inzwischen geändert/], [429, /eine Minute/], [500, /Internetverbindung/]]) {
h.controls.error = status;
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-status').textContent, text);
assert.equal(h.nodes.get('group-join').disabled, false);
}
h.controls.error = 0; h.controls.offline = true;
h.context.Wegwichtel.I18n.setLanguage('en', null);
pointer(h.nodes.get('group-join')); await tick();
assert.match(h.nodes.get('group-status').textContent, /internet connection/);
await h.groups.refresh();
});
test('a late join response does not reopen a cancelled dialog or editor', async () => {
const h = await harness();
const original = h.context.fetch;
let release;
h.context.fetch = async (url, options) => {
if (url.endsWith('/join')) await new Promise(resolve => { release = resolve; });
return original(url, options);
};
pointer(h.nodes.get('group-join-open'));
pointer(h.nodes.get('group-join'));
await tick();
pointer(h.nodes.get('group-close'));
release();
await tick();
assert.equal(h.nodes.get('group-dialog').open, false);
assert.deepEqual(h.edits, []);
});
test('unsent station changes are listed before network access and discarded only after confirmation', async () => {
const h = await harness();
const list = new Element(); h.nodes.set('editor-point-drafts', list);
const drafts = [{ route: { id: 7, name: 'Waldweg' }, poiId: 8, values: { title: 'Blumen' } }];
const opened = [];
h.context.Wegwichtel.EditorUi.openPendingPoint = async draft => opened.push(draft.poiId);
h.context.Wegwichtel.GroupPoints = { async list() { return drafts; }, async remove() { drafts.splice(0); } };
h.controls.offline = true;
await h.groups.refresh();
assert.equal(list.children.length, 1);
pointer(list.children[0].children[1].children[0]); await tick();
assert.deepEqual(opened, [8]);
h.controls.confirm = false;
pointer(list.children[0].children[1].children[1]); await tick();
assert.equal(drafts.length, 1);
h.controls.confirm = true;
pointer(list.children[0].children[1].children[1]); await tick();
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
assert.equal(list.children.length, 0);
});
+1 -1
View File
@@ -17,7 +17,7 @@ test('start screen exposes cookie status, optional local progress and deliberate
const loader = await read('public/js/bootstrap-loader.js');
const storage = await read('public/js/privacy-storage.js');
assert.match(html, /Wegwichtel verwendet keine Cookies/);
assert.match(html, /Wegwichtel verwendet keine Werbe- oder Tracking-Cookies/);
assert.match(html, /class="cookie-toggle"[^>]*disabled/);
assert.match(html, /class="progress-storage-toggle"/);
assert.match(html, /role="switch"[^>]*aria-checked="false"/);
+1 -1
View File
@@ -34,7 +34,7 @@ test('every static HTML id is referenced by client JavaScript or accessible HTML
test('every static HTML class is used by CSS or authored JavaScript', async () => {
const html = await read('public/index.html');
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}`;
const css = `${await read('public/css/bootstrap.css')}\n${await read('public/css/app.css')}\n${await read('public/css/editor.css')}`;
const scripts = (await clientSources()).join('\n');
const classes = [...html.matchAll(/\bclass="([^"]+)"/g)]
.flatMap(match => match[1].split(/\s+/))
+21
View File
@@ -29,6 +29,27 @@ const EXPECTED_SCHEMA = Object.freeze({
activated_at: { type: 'TEXT' },
trash_path: { type: 'TEXT' }
},
collaboration_sessions: {
token_hash: { type: 'TEXT', notnull: 1, pk: 1 },
creator_id: { type: 'TEXT', notnull: 1 },
expires_at: { type: 'INTEGER', notnull: 1 }
},
collaboration_groups: {
id: { type: 'TEXT', notnull: 1, pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },
source_route_id: { type: 'INTEGER' },
owner_creator_id: { type: 'TEXT', notnull: 1 },
code_hash: { type: 'TEXT' },
code_expires_at: { type: 'INTEGER', notnull: 1 },
active: { type: 'INTEGER', notnull: 1 },
revision: { type: 'INTEGER', notnull: 1 },
created_at: { type: 'TEXT', notnull: 1 }
},
collaboration_members: {
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
session_hash: { type: 'TEXT', notnull: 1, pk: 2 },
role: { type: 'TEXT', notnull: 1 }
},
pois: {
id: { type: 'INTEGER', pk: 1 },
route_id: { type: 'INTEGER', notnull: 1 },