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, maintainCollaboration, startCollaborationMaintenance } = await import('../src/services/collaboration.js'); const express = (await import('express')).default; const gpx = 'Waldweg'; 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', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201); assert.equal(created.code.length, 5); 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', { code: Array(5).fill('🪨') })).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', { code: Array(5).fill('🪨') })).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', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'https://other.test' }); await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' }); await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: 900, creator_id: 'a'.repeat(64) }, 404); const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🪨') })).value; const b = (await request(guest, lockPath(second.id), 'POST', { code: Array(5).fill('🪨') })).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); }); test('five-symbol invitations allow repeated motifs and reject other code lengths', async t => { const { source, request } = await fixture(t); const host = {}, guest = {}; const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; assert.deepEqual(created.code, Array(5).fill('🐦')); await request(guest, '/groups/join', 'POST', { code: Array(4).fill('🐦'), creator_id: 'b'.repeat(64) }, 404); await request(guest, '/groups/join', 'POST', { code: Array(6).fill('🐦'), creator_id: 'b'.repeat(64) }, 404); const joined = (await request(guest, '/groups/join', 'POST', { code: Array(5).fill('🐦'), creator_id: 'b'.repeat(64) })).value; assert.equal(joined.group.id, created.group.id); }); test('invitations extend on editing, expire after sixty idle minutes and never rotate', async t => { const { db, source, request } = await fixture(t); const host = {}, guest = {}; const started = Date.now(); let clock = started; t.mock.method(Date, 'now', () => clock); const created = (await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; assert.equal(created.expiresAt, Math.floor(started / 1000) + 3600); await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }); const group = created.group, route = group.route, poi = route.pois[0]; const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`; const lock = (await request(guest, endpoint, 'POST', { code: Array(5).fill('🪨') })).value; const file = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`); const firstActivity = JSON.parse(fs.readFileSync(file)).updated_at; clock += 31 * 60_000; maintainCollaboration(db); const held = (await request(host, `/groups/${group.id}/invitation`)).value; assert.equal(held.busy, true); assert.equal(held.expiresAt, created.expiresAt); assert.deepEqual(held.code, created.code); assert.equal(held.expired, false); await request(guest, `/groups/${group.id}/invitation`, 'GET', null, 403); await request(host, `/groups/${group.id}/code`, 'POST', {}, 423); await request(host, endpoint, 'GET', null, 423); assert.equal(JSON.parse(fs.readFileSync(file)).updated_at, firstActivity, 'status queries must not count as activity'); await request(host, endpoint, 'POST', { heartbeat: true, token: lock.token }, 423); await request(guest, endpoint, 'POST', { heartbeat: true, token: lock.token }, 204); assert.notEqual(JSON.parse(fs.readFileSync(file)).updated_at, firstActivity); clock = started + 90 * 60_000; maintainCollaboration(db); assert.equal(fs.existsSync(file), true, 'recent real activity preserves the lock'); clock = started + 91 * 60_000; const stop = startCollaborationMaintenance(db); stop(); assert.equal(fs.existsSync(file), false, 'sixty idle minutes remove the actual lock file'); await request(host, endpoint, 'GET', null, 204); await request(guest, endpoint, 'POST', { heartbeat: true, token: lock.token }, 423); const renewed = (await request(host, `/groups/${group.id}/invitation`)).value; assert.equal(renewed.busy, false); assert.equal(renewed.code.length, 5); assert.equal(renewed.expiresAt, Math.floor(started / 1000) + 91 * 60); assert.equal(renewed.expired, true); assert.deepEqual(renewed.code, created.code); await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) }, 404); await request(host, endpoint.replace(String(poi.id) + '/lock', '99999/lock'), 'GET', null, 404); }); test('chosen codes persist across connections, collisions fail without copies and passive queries never extend expiry', async t => { const { db, source, request } = await fixture(t); const host = {}, guest = {}; const code = ['🐦', '🪨', '🐦', '🎃', '🍄']; await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 400); const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 201)).value; assert.deepEqual(created.code, code); const second = openDatabase(db.location()); try { assert.deepEqual(JSON.parse(second.prepare('SELECT code FROM collaboration_invitations WHERE group_id = ?').get(created.group.id).code), code); } finally { second.close(); } const total = db.prepare('SELECT COUNT(*) AS total FROM routes').get().total; const collision = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64), code }, 409); assert.equal(collision.value.details.code, 'code-taken'); assert.equal(db.prepare('SELECT COUNT(*) AS total FROM routes').get().total, total); await request(host, `/groups/${created.group.id}/code`, 'POST', {}, 400); let clock = Date.now(); t.mock.method(Date, 'now', () => clock); clock += 5 * 60_000; const status = (await request(host, `/groups/${created.group.id}/invitation`)).value; assert.deepEqual(status.code, code); assert.equal(status.expiresAt, created.expiresAt); await request(guest, '/groups/join', 'POST', { code, creator_id: 'b'.repeat(64) }); const expiry = () => db.prepare('SELECT code_expires_at FROM collaboration_groups WHERE id = ?').get(created.group.id).code_expires_at; assert.equal(expiry(), Math.floor(clock / 1000) + 3600); clock += 5 * 60_000; await request(host, '/groups'); await request(guest, `/routes/${created.group.route.id}`); assert.equal(expiry(), Math.floor(clock / 1000) + 3300); await request(guest, `/groups/${created.group.id}/edit`, 'POST', {}); assert.equal(expiry(), Math.floor(clock / 1000) + 3600); await request({}, `/groups/${created.group.id}/edit`, 'POST', {}, 401); clock += 5 * 60_000; await request(guest, `/routes/${created.group.route.id}`, 'PUT', { name: 'Changed again' }, 200, { 'If-Match': '0' }); assert.equal(expiry(), Math.floor(clock / 1000) + 3600); await request(host, `/groups/${created.group.id}`, 'DELETE'); assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_invitations').get().total, 0); });