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