group code popup
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import nodeCrypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -15,7 +16,7 @@ const { createPicture, createAudio } = await import('../src/services/media-servi
|
||||
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 { 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>';
|
||||
|
||||
@@ -58,7 +59,7 @@ test('shared draft clones a published route and protects its contents and owners
|
||||
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.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/);
|
||||
@@ -238,3 +239,60 @@ test('different station edits do not conflict through the route revision, and in
|
||||
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);
|
||||
t.mock.method(nodeCrypto, 'randomInt', () => 5);
|
||||
const host = {}, guest = {};
|
||||
const created = (await request(host, '/groups', 'POST', { 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 expire after thirty minutes, wait for active locks and rotate after sixty idle minutes', 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', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value;
|
||||
assert.equal(created.expiresAt, Math.floor(started / 1000) + 1800);
|
||||
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', {})).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);
|
||||
await request({}, '/groups/join', 'POST', { code: created.code, creator_id: 'c'.repeat(64) }, 404);
|
||||
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(clock / 1000) + 1800);
|
||||
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) });
|
||||
await request(host, endpoint.replace(String(poi.id) + '/lock', '99999/lock'), 'GET', null, 404);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,8 @@ async function harness(records = new Map()) {
|
||||
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 };
|
||||
if (body.heartbeat && !controls.locked) { status = 204; payload = {}; }
|
||||
else 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 };
|
||||
@@ -131,3 +132,23 @@ test('offline drafts remain editable without claiming a server lock and a late l
|
||||
assert.equal(await opening, null);
|
||||
assert.equal(Object.keys(h.api.headersFor('/routes/7/pois/8')).length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('only actual editor activity refreshes a point lock, while passive time and status checks do not', async () => {
|
||||
const h = await harness();
|
||||
await h.api.begin(7, h.poi);
|
||||
await h.api.keepActive();
|
||||
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 0);
|
||||
h.api.noteActivity(); await h.api.keepActive(); await h.api.keepActive();
|
||||
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 1);
|
||||
assert.equal(h.api.headersFor('/routes/7/pois/8')['If-Poi-Match'], 'v1');
|
||||
h.controls.offline = true;
|
||||
h.api.noteActivity(); await h.api.keepActive();
|
||||
h.controls.offline = false; await h.api.keepActive();
|
||||
assert.equal(h.calls.filter(call => call.body.heartbeat).length, 3);
|
||||
let lost = 0; h.context.document.addEventListener('wegwichtel:point-lock-lost', () => { lost += 1; });
|
||||
h.controls.locked = true; h.api.noteActivity(); await h.api.keepActive();
|
||||
assert.equal(lost, 1);
|
||||
assert.equal(h.calls.at(-1).body.heartbeat, true);
|
||||
assert.equal(h.calls.at(-1).body.replace, undefined);
|
||||
});
|
||||
|
||||
+55
-7
@@ -12,7 +12,7 @@ class Element extends EventTarget {
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; }
|
||||
close() { this.open = false; this.dispatchEvent(new Event('close')); }
|
||||
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)); };
|
||||
@@ -27,6 +27,7 @@ function key(node, value) {
|
||||
}
|
||||
async function harness(loading = false) {
|
||||
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
|
||||
'group-share-dialog', 'group-share-route', 'group-share-pictures', 'group-share-start', 'group-share-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();
|
||||
@@ -48,7 +49,7 @@ async function harness(loading = false) {
|
||||
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 };
|
||||
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
|
||||
return { ok: true, json: async () => value };
|
||||
}
|
||||
};
|
||||
@@ -69,7 +70,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
||||
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);
|
||||
assert.equal(columns.length, 5);
|
||||
const [up, viewport, down] = columns[0].children;
|
||||
assert.equal(viewport.attributes.get('role'), 'spinbutton');
|
||||
key(up, 'Enter');
|
||||
@@ -86,7 +87,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
||||
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.length, 5);
|
||||
assert.equal(body.code[0], '🌼');
|
||||
assert.equal(body.creator_id, 'a'.repeat(64));
|
||||
assert.deepEqual(h.edits, [22]);
|
||||
@@ -96,16 +97,17 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
|
||||
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 () => {
|
||||
test('sharing displays five 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-code').children.length, 5);
|
||||
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);
|
||||
assert.equal(shareButton.textContent, '👥');
|
||||
assert.equal(shareButton.attributes.get('aria-haspopup'), 'dialog');
|
||||
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;
|
||||
@@ -186,3 +188,49 @@ test('unsent station changes are listed before network access and discarded only
|
||||
h.context.document.dispatchEvent(new Event('wegwichtel:point-drafts-changed')); await tick();
|
||||
assert.equal(list.children.length, 0);
|
||||
});
|
||||
|
||||
test('compact sharing button opens a large gnome preview and requires confirmation before starting', async () => {
|
||||
const h = await harness();
|
||||
const decision = h.groups.confirmShare('Blumenweg');
|
||||
const dialog = h.nodes.get('group-share-dialog');
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(h.nodes.get('group-share-route').textContent, 'Blumenweg');
|
||||
const pictures = h.nodes.get('group-share-pictures').children;
|
||||
assert.equal(pictures.length, 2);
|
||||
assert.match(pictures[0].src, /🐦/);
|
||||
assert.match(pictures[1].src, /🪨/);
|
||||
assert.equal(h.calls.length, 0);
|
||||
assert.equal(await h.groups.confirmShare('Zweiter Weg'), false);
|
||||
pointer(h.nodes.get('group-share-close'));
|
||||
assert.equal(await decision, false);
|
||||
const accepted = h.groups.confirmShare('Blumenweg');
|
||||
key(h.nodes.get('group-share-start'), 'Enter');
|
||||
assert.equal(await accepted, true);
|
||||
assert.equal(dialog.open, false);
|
||||
const dismissed = h.groups.confirmShare('Blumenweg');
|
||||
dialog.close();
|
||||
assert.equal(await dismissed, false);
|
||||
});
|
||||
|
||||
|
||||
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => {
|
||||
const h = await harness();
|
||||
await h.groups.share(7);
|
||||
const original = h.context.fetch;
|
||||
h.context.fetch = async (url, options) => {
|
||||
if (!url.endsWith('/invitation')) return original(url, options);
|
||||
return { ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
|
||||
busy: true, expired: true, expiresAt: 1800000000 }) };
|
||||
};
|
||||
await h.groups.refreshInvitation();
|
||||
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/);
|
||||
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' },
|
||||
busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🪨') }) });
|
||||
await h.groups.refreshInvitation();
|
||||
assert.equal(h.nodes.get('group-code').children.length, 5);
|
||||
assert.match(h.nodes.get('group-code').children[0].src, /🪨/);
|
||||
assert.equal(h.nodes.get('group-status').textContent, '');
|
||||
h.nodes.get('group-dialog').close();
|
||||
h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); };
|
||||
await h.groups.refreshInvitation();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user