338 lines
23 KiB
JavaScript
338 lines
23 KiB
JavaScript
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 = '<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', { 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);
|
|
});
|