fix tests and change invitation mechanics
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Successful in 24s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m2s
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-19 17:21:50 +02:00
parent a66a795407
commit cacc63be66
13 changed files with 349 additions and 146 deletions
+5
View File
@@ -90,3 +90,8 @@ CREATE TABLE IF NOT EXISTS collaboration_members (
role TEXT NOT NULL CHECK (role IN ('owner', 'member')), role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
PRIMARY KEY (group_id, session_hash) PRIMARY KEY (group_id, session_hash)
) STRICT; ) STRICT;
CREATE TABLE IF NOT EXISTS collaboration_invitations (
group_id TEXT PRIMARY KEY NOT NULL REFERENCES collaboration_groups(id) ON DELETE CASCADE,
code TEXT NOT NULL
) STRICT;
+32 -23
View File
@@ -1,18 +1,18 @@
# Gemeinsam bearbeiten # Gemeinsam bearbeiten
In „Strecken bearbeiten“ öffnet der kleine Button mit zwei gezeichneten Wichteln In „Strecken bearbeiten“ öffnet der kleine Button mit zwei gezeichneten Wichteln
zwischen Stift und zwischen Stift und Löschen zuerst ein großes Popup zur Codeauswahl. Die fünf
Löschen den gemeinsamen Entwurf und zeigt anschließend den echten Code mit Drehräder sind zufällig vorbelegt. Du kannst jeden Wichtel ändern; Wiederholungen
fünf Wichteln in einem großen Popup an. Eine vorgeschaltete Zwei-Wichtel-Vorschau sind erlaubt. Erst „Diesen Code verwenden“ bestätigt deine Auswahl. Abbrechen
gibt es nicht mehr. Das Original bleibt unverändert. Bei lokalen Entwürfen legt keine Gruppe an und überträgt keine Strecke. Bei lokalen Entwürfen fragt
fragt die App vorher, ob Weg, Stationen, Bilder und Ton übertragen werden dürfen. die App danach, ob Weg, Stationen, Bilder und Ton übertragen werden dürfen.
Ein bereits eingesandter eigener Entwurf wird dabei zuerst aktualisiert. Ein bereits eingesandter eigener Entwurf wird zuerst aktualisiert. Anschließend
zeigt ein eigenes Popup den gespeicherten Code. Die Bearbeitung beginnt erst
mit „Entwurf bearbeiten“. Das Original bleibt unverändert.
Danach zeigt die App fünf Wichtel als Gruppencode. Andere öffnen in der Andere öffnen in der Streckenverwaltung „Gruppencode eingeben“. Dort schieben
Streckenverwaltung „Gruppencode eingeben“. Ein eigenes breites Popup bietet sie die Bilder in fünf Drehrädern nach oben oder unten. Es zählen die Wichtel
Platz für alle fünf Drehräder. Dort schieben sie die Bilder in den fünf in der Mitte, von links nach rechts. Pfeiltasten und Pfeilschaltflächen
Drehrädern nach oben oder unten. Es zählen die Wichtel in der Mitte, von links
nach rechts. Wiederholungen sind erlaubt. Pfeiltasten und die Pfeilschaltflächen
funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf. funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
## Arbeiten und Einladungen ## Arbeiten und Einladungen
@@ -25,12 +25,19 @@ funktionieren ebenfalls. „Mitmachen“ öffnet den gemeinsamen Entwurf.
- Wenn jemand zwischenzeitlich gespeichert hat, weist die App den veralteten - Wenn jemand zwischenzeitlich gespeichert hat, weist die App den veralteten
Speicherversuch zurück. Die Eingaben bleiben stehen und können vor dem Laden Speicherversuch zurück. Die Eingaben bleiben stehen und können vor dem Laden
des neuen Stands gesichert werden. Es gibt noch keine automatische Zusammenführung. des neuen Stands gesichert werden. Es gibt noch keine automatische Zusammenführung.
- Der Einladungscode gilt 30 Minuten. Anschließend erzeugt die Website automatisch - Der Einladungscode gilt 60 Minuten. Erfolgreiches Mitmachen, Öffnen zur
einen neuen Code, sobald keine Station mehr gesperrt ist. Ein abgelaufener Code Bearbeitung, Übernehmen einer Station, gemeldete tatsächliche Bearbeitungsaktivität
erlaubt auch während einer Bearbeitung keinen neuen Beitritt. Bereits beigetretene und erfolgreiches Speichern setzen die Frist erneut auf 60 Minuten.
Personen behalten ihren Zugang. Das geöffnete Codefenster prüft alle 30 Sekunden Listen, Codeanzeige, reine Datenabrufe und Sperrstatusabfragen verlängern sie nicht.
auf einen neuen Code. Die eröffnende Person kann mit „Neuen Code anzeigen“ auch - Das Code-Popup zeigt Ablaufzeit und verbleibende Minuten und Sekunden. Die
früher wechseln; während einer Stationsbearbeitung wartet auch dieser Wechsel. Restzeit zählt herunter; neue Aktivität wird spätestens nach 30 Sekunden übernommen.
- Der Code wird niemals automatisch geändert oder neu erzeugt. Nach Ablauf
erlaubt er keinen neuen Beitritt. Bereits beigetretene Personen behalten ihren
Zugang und können durch erneute Bearbeitung die Gültigkeit desselben Codes verlängern.
- „Gruppencode“ zeigt den gespeicherten Code erneut an. „Code auswählen“ öffnet
die Auswahl; nur eine ausdrückliche Bestätigung ändert oder reaktiviert den Code.
Während einer Stationsbearbeitung wartet ein Codewechsel, bis die Stationen frei sind.
Bereits verwendete Codes anderer Gruppen werden mit einem Hinweis abgewiesen.
- „Gruppe schließen“ entfernt den Zugang der anderen Personen und den Code. - „Gruppe schließen“ entfernt den Zugang der anderen Personen und den Code.
Die eröffnende Person behält ihren Entwurf und kann wieder einen Code erzeugen. Die eröffnende Person behält ihren Entwurf und kann wieder einen Code erzeugen.
- Es sind höchstens 50 Zugänge je Gruppe und zwölf gleichzeitig offene Gruppen - Es sind höchstens 50 Zugänge je Gruppe und zwölf gleichzeitig offene Gruppen
@@ -74,7 +81,8 @@ npm run test:runtime-db
systemctl --user restart wegwichtel.service systemctl --user restart wegwichtel.service
``` ```
`init-db` legt die drei neuen STRICT-Tabellen an. Es verändert keine bestehenden `init-db` legt fehlende STRICT-Tabellen an, einschließlich `collaboration_invitations`
für die dauerhaft gespeicherten Codes. Es verändert keine bestehenden
Streckentabellen und löscht keine Strecken. Eine Neuerzeugung oder das Löschen Streckentabellen und löscht keine Strecken. Eine Neuerzeugung oder das Löschen
der vorhandenen Datenbank ist hierfür nicht erforderlich. der vorhandenen Datenbank ist hierfür nicht erforderlich.
@@ -154,8 +162,9 @@ zur vorhandenen Anzeige des belegten Gerätespeichers. Browserdaten-Löschung ka
sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte sie entfernen. Sie enthalten nur die betroffene Station und neu ausgewählte
Dateien; vorhandene Aufnahmen werden hierfür nicht gesammelt heruntergeladen. Dateien; vorhandene Aufnahmen werden hierfür nicht gesammelt heruntergeladen.
Einladungen werden dauerhaft weiterhin nur als Hash gespeichert. Den neu erzeugten Die ausgewählten Codes liegen in `collaboration_invitations`, der zugehörige
Klartext hält der App-Prozess im Arbeitsspeicher für die berechtigte Anzeige vor. SHA-256-Abdruck und Ablaufzeitpunkt in `collaboration_groups`. Nur die berechtigte
Nach einem App-Neustart bleibt der zuletzt gültige Code weiterhin bis zu seinem eröffnende Sitzung kann den Code abrufen. Nach einem App-Neustart wird derselbe
Ablauf verwendbar. Falls er auf dem Gerät nicht mehr angezeigt wird, kann die Code wieder angezeigt; es findet kein automatischer Ersatz statt. Bestehende
eröffnende Person bei freien Stationen einen neuen Code anfordern. Gruppen aus der vorherigen Version enthalten noch keinen gespeicherten Klartext.
Für sie muss die eröffnende Person einmal ausdrücklich einen Code auswählen.
+21 -16
View File
@@ -15,10 +15,13 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
- Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict- - Sitzungsgeheimnisse nur als Hash serverseitig und als HttpOnly-/SameSite-Strict-
Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben. Cookie auf dem Client speichern; bei HTTPS zusätzlich Secure. Nicht ausgeben.
- Einladungscodes haben fünf Stellen mit den vorhandenen neun Symbolen. - Einladungscodes haben fünf Stellen mit den vorhandenen neun Symbolen.
Wiederholungen sind erlaubt; jede Stelle wird unabhängig mit randomInt Wiederholungen sind erlaubt. Das Auswahlpopup wird zufällig vorbelegt; erst die
gewählt. Codes werden dauerhaft nur gehasht gespeichert; der aktuelle Klartext wird für Bestätigung speichert den vom Nutzer gewählten Code. Server erzeugt oder
die Besitzeranzeige im Arbeitsspeicher des App-Prozesses gehalten. Gültigkeit 30 Minuten; falsche Versuche ersetzt Codes niemals automatisch. Ungültige bzw. belegte Codes zurückweisen.
sind begrenzt. Variationselektoren ändern die Unicode-Zuordnung nicht. - Codes dauerhaft in collaboration_invitations speichern; SHA-256-Abdruck und
Ablaufzeitpunkt bleiben in collaboration_groups. Klartext nur für Besitzer
ausliefern, auch nach Neustart. Gültigkeit 60 Minuten; falsche Versuche begrenzen.
Variationselektoren ändern die Unicode-Zuordnung nicht.
- Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen- - Schutz auch auf den ursprünglichen /api/routes/...-Adressen für alle Gruppen-
Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung, Dateien durchsetzen. Anders geschriebene IDs (führende Nullen, Prozentkodierung,
Exponentialschreibweise, Groß-/Kleinschreibung der Pfade) dürfen ihn nicht umgehen. Exponentialschreibweise, Groß-/Kleinschreibung der Pfade) dürfen ihn nicht umgehen.
@@ -80,10 +83,12 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme entfernter Stationen sowie verteilte Sperren. Eine nachfolgende Aufnahme
kann nicht ohne Einschränkungen zurückgerufen werden. kann nicht ohne Einschränkungen zurückgerufen werden.
- Einladungscodes nach 30 Minuten automatisch erneuern, solange keine aktive - Einladungscodes laufen nach 60 Minuten ohne Nutzung ab, bleiben unverändert
Stationssperre vorliegt. Abgelaufene Codes erlauben keinen neuen Beitritt, auch und erlauben dann keinen neuen Beitritt. Erfolgreicher Beitritt, explizites Öffnen
wenn ihre Erneuerung wegen aktiver Stationen aufgeschoben ist. Mitgliedschaften zur Bearbeitung, Sperrerwerb, gültige Aktivitätsmeldung und erfolgreiche Speicherung
bleiben erhalten. Wartung im Serverprozess starten und beim Beenden stoppen. verlängern auf 60 Minuten. Bestehende Mitgliedschaften bleiben erhalten und
können durch Bearbeitung denselben Code reaktivieren. Passive Abfragen ändern
keine Frist. Wartung bereinigt nur abgelaufene Stationssperren, niemals Codes.
- Sperren nach 60 Minuten ohne Aktivität entfernen; updated_at bei tatsächlicher - Sperren nach 60 Minuten ohne Aktivität entfernen; updated_at bei tatsächlicher
Bearbeitung/Speicherung aktualisieren. Reine Statusabfragen und unbenutzte Bearbeitung/Speicherung aktualisieren. Reine Statusabfragen und unbenutzte
Fenster dürfen die Frist nicht verlängern. Laufende Schreibanfragen nicht Fenster dürfen die Frist nicht verlängern. Laufende Schreibanfragen nicht
@@ -95,11 +100,11 @@ Sitzungen, Rollen owner/member, Codewechsel und Widerruf. Bedienung und Einspiel
## Code-Popups ## Code-Popups
- Gruppenbutton zeigt unmittelbar den echten fünfstelligen Einladungscode. Keine - Gruppenbutton öffnet zuerst fünf zufällig vorbelegte, veränderbare Drehräder.
vorgeschaltete Zwei-Wichtel-Vorschau wieder einführen; sie wurde mit dem Code Auswahl vor Übertragung lokaler Strecken bestätigen lassen. Abbrechen überträgt
verwechselt. Eine unvollständige Codeantwort nicht anzeigen. nichts. Danach gespeicherten Code anzeigen; Editor erst nach ausdrücklicher Aktion.
- Codeanzeige und Codeeingabe verwenden getrennte breite native Dialoge. Alle - Auswahl, Beitritt und Codeanzeige verwenden breite native Dialoge. Alle fünf
fünf Drehräder nebeneinander anordnen; Wiederholungen weiterhin zulassen. Drehräder nebeneinander; Wiederholungen zulassen. Codeänderung nur ausdrücklich.
- Scrollschritt in groups.js und Optionshöhe/Innenabstand in editor.css müssen - Scrollschritt in groups.js und Optionshöhe/Innenabstand in editor.css stimmen
übereinstimmen (96 Pixel). Schließen der Eingabe verwirft verspätete Antworten; überein (96 Pixel). Schließen der Eingabe verwirft verspätete Antworten;
die automatische Einladungsaktualisierung darf die Eingabe nicht überdecken. die passive Einladungsabfrage darf die Eingabe nicht überdecken.
+3 -1
View File
@@ -1652,6 +1652,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (shareInProgress) return; if (shareInProgress) return;
shareInProgress = true; shareInProgress = true;
try { try {
const code = await ns.Groups.chooseCode();
if (!code) return;
let sourceId = routeId(route); let sourceId = routeId(route);
if (routeSource(route) === 'local') { if (routeSource(route) === 'local') {
if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return; if (!globalThis.confirm(ns.I18n.t('groups.upload-confirm'))) return;
@@ -1659,7 +1661,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await uploadLocalRoute(); await uploadLocalRoute();
sourceId = state.currentRoute.serverRouteId; sourceId = state.currentRoute.serverRouteId;
} }
await ns.Groups.share(sourceId); await ns.Groups.share(sourceId, code);
} finally { shareInProgress = false; } } finally { shareInProgress = false; }
return; return;
} }
+78 -21
View File
@@ -13,7 +13,9 @@ globalThis.Wegwichtel ??= {};
const wheelStep = 96; const wheelStep = 96;
const revisions = new Map(); const revisions = new Map();
let selection = []; let selection = [];
let codeChoice = null;
let currentGroup = null; let currentGroup = null;
let currentExpiry = 0;
let pending = false; let pending = false;
let dialogGeneration = 0; let dialogGeneration = 0;
const element = id => document.getElementById(id); const element = id => document.getElementById(id);
@@ -42,6 +44,8 @@ globalThis.Wegwichtel ??= {};
} }
function errorMessage(error) { function errorMessage(error) {
if (error.reason === 'code-taken') return t('error.code-taken');
if (error.reason === 'invalid-code') return t('error.code');
if (error.reason === 'group-limit') return t('error.limit'); if (error.reason === 'group-limit') return t('error.limit');
if (error.reason === 'group-full') return t('error.full'); if (error.reason === 'group-full') return t('error.full');
if (error.reason === 'group-editing') return t('code-wait'); if (error.reason === 'group-editing') return t('code-wait');
@@ -141,33 +145,68 @@ globalThis.Wegwichtel ??= {};
event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1); event.preventDefault(); choose(event.key === 'Home' ? 0 : motifs.length - 1);
} }
}); });
paint(0); paint(selection[position]);
column.append(up, viewport, down); column.append(up, viewport, down);
return column; return column;
} }
function openEntry() { function openEntry(chooseCode = false) {
if (element(ids.dialog).open) element(ids.dialog).close(); if (element(ids.dialog).open) element(ids.dialog).close();
dialogGeneration += 1; dialogGeneration += 1;
selection = new Array(codeLength).fill(0); selection = Array.from({ length: codeLength }, () => chooseCode ? randomIndex(ns.GroupSymbols.list().length) : 0);
element('group-entry-title').setAttribute('data-i18n', `groups.${chooseCode ? 'choose-code' : 'join-open'}`);
element('group-entry-title').textContent = t(chooseCode ? 'choose-code' : 'join-open');
element(ids.join).setAttribute('data-i18n', `groups.${chooseCode ? 'save-code' : 'join'}`);
element(ids.join).disabled = false;
element(ids.join).textContent = t(chooseCode ? 'save-code' : 'join');
element(ids.entryStatus).textContent = ''; element(ids.entryStatus).textContent = '';
if (!element(ids.entryDialog).open) element(ids.entryDialog).showModal();
const wheels = element(ids.wheels); const wheels = element(ids.wheels);
wheels.replaceChildren(...selection.map((_, index) => makeWheel(index))); wheels.replaceChildren(...selection.map((_, index) => makeWheel(index)));
if (!element(ids.entryDialog).open) element(ids.entryDialog).showModal(); for (const [index, column] of Array.from(wheels.children).entries()) column.children[1].scrollTop = selection[index] * wheelStep;
}
function randomIndex(size) {
const values = new Uint32Array(1);
const limit = Math.floor(0x100000000 / size) * size;
do { globalThis.crypto.getRandomValues(values); } while (values[0] >= limit);
return values[0] % size;
}
function chooseCode() {
if (codeChoice) return Promise.resolve(null);
openEntry(true);
return new Promise(resolve => { codeChoice = resolve; });
}
function cancelChoice() {
dialogGeneration += 1;
const resolve = codeChoice;
codeChoice = null;
resolve?.(null);
}
function updateCountdown() {
if (!currentGroup || !element(ids.dialog).open || element(ids.invitation).hidden) return;
const seconds = Math.max(0, Math.ceil(currentExpiry - Date.now() / 1000));
const time = new Date(currentExpiry * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' });
element(ids.expiry).textContent = seconds ? `${t('expires', { time })} ${t('remaining', {
minutes: Math.floor(seconds / 60), seconds: String(seconds % 60).padStart(2, '0')
})}` : t('code-expired');
} }
function showInvitation(payload) { function showInvitation(payload) {
if (!Array.isArray(payload.code) || payload.code.length !== codeLength) throw new Error(t('error.connection')); if (!Array.isArray(payload.code) || payload.code.length !== codeLength) throw new Error(t('error.connection'));
currentGroup = payload.group; currentGroup = payload.group;
element(ids.status).textContent = ''; element(ids.status).textContent = payload.expired ? t('code-expired') : '';
if (element(ids.entryDialog).open) element(ids.entryDialog).close(); if (element(ids.entryDialog).open) element(ids.entryDialog).close();
element(ids.invitation).hidden = false; element(ids.invitation).hidden = false;
element('group-route').textContent = payload.group.route.name || ''; element('group-route').textContent = payload.group.route.name || '';
const motifs = payload.code.map(value => ns.GroupSymbols.get(value)); const motifs = payload.code.map(value => ns.GroupSymbols.get(value));
element(ids.code).replaceChildren(...motifs.map(picture)); element(ids.code).replaceChildren(...motifs.map(picture));
const time = new Date(payload.expiresAt * 1000).toLocaleTimeString(ns.I18n.language, { hour: '2-digit', minute: '2-digit' }); currentExpiry = payload.expiresAt;
element(ids.expiry).textContent = t('expires', { time });
if (!element(ids.dialog).open) element(ids.dialog).showModal(); if (!element(ids.dialog).open) element(ids.dialog).showModal();
updateCountdown();
} }
let invitationPolling = false; let invitationPolling = false;
@@ -179,14 +218,15 @@ globalThis.Wegwichtel ??= {};
const payload = await request(`/${group.id}/invitation`); const payload = await request(`/${group.id}/invitation`);
if (currentGroup?.id !== group.id || !element(ids.dialog).open || element(ids.invitation).hidden) return; if (currentGroup?.id !== group.id || !element(ids.dialog).open || element(ids.invitation).hidden) return;
if (payload.code) showInvitation(payload); if (payload.code) showInvitation(payload);
element(ids.status).textContent = payload.busy && payload.expired ? t('code-wait') : ''; element(ids.status).textContent = payload.expired ? t('code-expired') : '';
} catch { /* Keep the invitation visible; expiry still applies on the server. */ } } catch { /* Keep the invitation visible; expiry still applies on the server. */ }
finally { invitationPolling = false; } finally { invitationPolling = false; }
} }
async function edit(group) { async function edit(group) {
if (element(ids.dialog).open) element(ids.dialog).close(); if (element(ids.dialog).open) element(ids.dialog).close();
if (element(ids.entryDialog).open) element(ids.entryDialog).close(); if (element(ids.entryDialog).open) element(ids.entryDialog).close();
await ns.EditorUi.openGroup(group.route); const payload = await request(`/${group.id}/edit`, 'POST', {});
await ns.EditorUi.openGroup(payload.group.route);
} }
async function renderPending() { async function renderPending() {
@@ -228,8 +268,9 @@ globalThis.Wegwichtel ??= {};
}))); })));
if (group.role === 'owner') { if (group.role === 'owner') {
actions.appendChild(button(t('new-code'), () => guarded(async () => { actions.appendChild(button(t('new-code'), () => guarded(async () => {
if (!globalThis.confirm(t('new-code-confirm'))) return; const code = await chooseCode();
showInvitation(await request(`/${group.id}/code`, 'POST', {})); if (!code) return;
showInvitation(await request(`/${group.id}/code`, 'POST', { code }));
await refresh(); await refresh();
}))); })));
if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => { if (group.active) actions.appendChild(button(t('close-sharing'), () => guarded(async () => {
@@ -238,6 +279,11 @@ globalThis.Wegwichtel ??= {};
await refresh(); await refresh();
}))); })));
} }
if (group.role === 'owner' && group.active) actions.appendChild(button(t('code-label'), () => guarded(async () => {
const payload = await request(`/${group.id}/invitation`);
if (payload.code) showInvitation(payload);
else { const code = await chooseCode(); if (code) showInvitation(await request(`/${group.id}/code`, 'POST', { code })); }
})));
item.append(name, actions); item.append(name, actions);
list.appendChild(item); list.appendChild(item);
} }
@@ -249,11 +295,12 @@ globalThis.Wegwichtel ??= {};
} catch { /* Existing drafts and forms stay usable without a connection. */ } } catch { /* Existing drafts and forms stay usable without a connection. */ }
} }
async function share(routeId) { async function share(routeId, code = null) {
code ??= await chooseCode();
if (!code) return;
try { try {
const payload = await request('', 'POST', { routeId, creator_id: ns.LocalRoutes.creatorId() }); const payload = await request('', 'POST', { routeId, code, creator_id: ns.LocalRoutes.creatorId() });
const editor = document.getElementById('editor-dialog'); showInvitation(payload);
if (!editor || editor.open) showInvitation(payload);
await refresh(); await refresh();
} catch (error) { throw new Error(errorMessage(error)); } } catch (error) { throw new Error(errorMessage(error)); }
} }
@@ -287,8 +334,17 @@ globalThis.Wegwichtel ??= {};
} }
async function joinSelectedGroup() { async function joinSelectedGroup() {
const generation = dialogGeneration; if (!element(ids.entryDialog).open) return;
const motifs = ns.GroupSymbols.list(); const motifs = ns.GroupSymbols.list();
if (codeChoice) {
const resolve = codeChoice;
codeChoice = null;
const code = selection.map(index => motifs[index].unicode);
element(ids.entryDialog).close();
resolve(code);
return;
}
const generation = dialogGeneration;
let payload; let payload;
try { try {
payload = await request('/join', 'POST', { payload = await request('/join', 'POST', {
@@ -305,17 +361,18 @@ globalThis.Wegwichtel ??= {};
function init() { function init() {
globalThis.setInterval?.(() => { refreshInvitation(); }, 30_000); globalThis.setInterval?.(() => { refreshInvitation(); }, 30_000);
globalThis.setInterval?.(updateCountdown, 1000);
document.addEventListener('wegwichtel:point-drafts-changed', () => { renderPending(); ns.StorageUsage?.refresh(); }); document.addEventListener('wegwichtel:point-drafts-changed', () => { renderPending(); ns.StorageUsage?.refresh(); });
element(ids.entryDialog).addEventListener('cancel', () => { dialogGeneration += 1; }); element(ids.entryDialog).addEventListener('cancel', cancelChoice);
element(ids.entryDialog).addEventListener('close', () => { dialogGeneration += 1; }); element(ids.entryDialog).addEventListener('close', cancelChoice);
activate(element(ids.entryClose), () => element(ids.entryDialog).close()); activate(element(ids.entryClose), () => element(ids.entryDialog).close());
for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), openEntry); for (const id of [ids.joinOpen, ids.editorJoin]) activate(element(id), () => openEntry());
activate(element(ids.close), () => element(ids.dialog).close()); activate(element(ids.close), () => element(ids.dialog).close());
activate(element(ids.join), () => guarded(joinSelectedGroup)); activate(element(ids.join), () => codeChoice ? joinSelectedGroup() : guarded(joinSelectedGroup));
activate(element(ids.edit), () => guarded(() => edit(currentGroup))); activate(element(ids.edit), () => guarded(() => edit(currentGroup)));
} }
ns.Groups = Object.freeze({ share, shareButton, refresh, refreshInvitation, headersFor, observe }); ns.Groups = Object.freeze({ share, chooseCode, shareButton, refresh, refreshInvitation, headersFor, observe });
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true });
else init(); else init();
}(globalThis.Wegwichtel)); }(globalThis.Wegwichtel));
+7 -3
View File
@@ -134,7 +134,11 @@ globalThis.Wegwichtel ??= {};
"ready": "Alles ist bereit." "ready": "Alles ist bereit."
}, },
"groups": { "groups": {
"code-wait": "In der Gruppe wird gerade eine Station bearbeitet. Ein neuer Code erscheint, sobald die Stationen wieder frei sind. Ein abgelaufener Code erlaubt keinen neuen Beitritt.", "remaining": "Noch {minutes} Min. {seconds} Sek. gültig.",
"choose-code": "Code auswählen",
"save-code": "Diesen Code verwenden",
"code-expired": "Der Code ist abgelaufen. Wähle einen Code, um wieder andere einzuladen.",
"code-wait": "In der Gruppe wird gerade eine Station bearbeitet. Warte, bis die Stationen frei sind, bevor du den Code änderst.",
"title": "Gemeinsame Entwürfe", "title": "Gemeinsame Entwürfe",
"upload-confirm": "Dein Entwurf mit Bildern und Ton wird dafür an die Website gesendet und als gemeinsamer Entwurf kopiert. Fortfahren?", "upload-confirm": "Dein Entwurf mit Bildern und Ton wird dafür an die Website gesendet und als gemeinsamer Entwurf kopiert. Fortfahren?",
"share": "Gemeinsam bearbeiten", "share": "Gemeinsam bearbeiten",
@@ -147,8 +151,7 @@ globalThis.Wegwichtel ??= {};
"edit": "Entwurf bearbeiten", "edit": "Entwurf bearbeiten",
"reload": "Änderungen holen", "reload": "Änderungen holen",
"reload-confirm": "Ungespeicherte Eingaben werden ersetzt. Möchtest du den aktuellen gemeinsamen Stand holen?", "reload-confirm": "Ungespeicherte Eingaben werden ersetzt. Möchtest du den aktuellen gemeinsamen Stand holen?",
"new-code": "Neuen Code anzeigen", "new-code": "Code auswählen",
"new-code-confirm": "Der bisherige Code funktioniert danach nicht mehr. Bereits beigetretene Personen bleiben dabei. Neuen Code erstellen?",
"close-sharing": "Gruppe schließen", "close-sharing": "Gruppe schließen",
"close-confirm": "Die anderen können diesen Entwurf danach nicht mehr öffnen oder bearbeiten. Du behältst den Zugriff. Gruppe schließen?", "close-confirm": "Die anderen können diesen Entwurf danach nicht mehr öffnen oder bearbeiten. Du behältst den Zugriff. Gruppe schließen?",
"expires": "Der Code gilt bis {time}.", "expires": "Der Code gilt bis {time}.",
@@ -157,6 +160,7 @@ globalThis.Wegwichtel ??= {};
"next": "Nächster Wichtel an Stelle {number}", "next": "Nächster Wichtel an Stelle {number}",
"none": "Du bist noch in keiner Gruppe.", "none": "Du bist noch in keiner Gruppe.",
"error": { "error": {
"code-taken": "Diesen Code nutzt schon eine andere Gruppe. Wähle andere Wichtel.",
"limit": "Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.", "limit": "Schließe erst eine deiner Gruppen, bevor du eine weitere öffnest.",
"full": "Diese Gruppe ist bereits voll. Bitte um einen Code für eine andere Gruppe.", "full": "Diese Gruppe ist bereits voll. Bitte um einen Code für eine andere Gruppe.",
"rate": "Bitte warte eine Minute und versuche es dann erneut.", "rate": "Bitte warte eine Minute und versuche es dann erneut.",
+7 -3
View File
@@ -134,7 +134,11 @@ globalThis.Wegwichtel ??= {};
"ready": "Everything is ready." "ready": "Everything is ready."
}, },
"groups": { "groups": {
"code-wait": "Someone in the group is editing a stop. A new code will appear once the stops are free. An expired code cannot be used to join.", "remaining": "Valid for another {minutes} min {seconds} sec.",
"choose-code": "Choose a code",
"save-code": "Use this code",
"code-expired": "This code has expired. Choose a code to invite others again.",
"code-wait": "Someone is editing a stop. Wait until the stops are free before changing the code.",
"title": "Shared drafts", "title": "Shared drafts",
"upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?", "upload-confirm": "Your draft, including pictures and recordings, will be sent to the website and copied into a shared draft. Continue?",
"share": "Edit together", "share": "Edit together",
@@ -147,8 +151,7 @@ globalThis.Wegwichtel ??= {};
"edit": "Edit draft", "edit": "Edit draft",
"reload": "Get changes", "reload": "Get changes",
"reload-confirm": "Your unsaved changes will be replaced. Would you like to get the latest shared version?", "reload-confirm": "Your unsaved changes will be replaced. Would you like to get the latest shared version?",
"new-code": "Show a new code", "new-code": "Choose a code",
"new-code-confirm": "The old code will stop working. People who have already joined will keep their access. Create a new code?",
"close-sharing": "Close group", "close-sharing": "Close group",
"close-confirm": "The others will no longer be able to open or edit this draft. You will keep your access. Close the group?", "close-confirm": "The others will no longer be able to open or edit this draft. You will keep your access. Close the group?",
"expires": "This code works until {time}.", "expires": "This code works until {time}.",
@@ -157,6 +160,7 @@ globalThis.Wegwichtel ??= {};
"next": "Next gnome in position {number}", "next": "Next gnome in position {number}",
"none": "You have not joined a group yet.", "none": "You have not joined a group yet.",
"error": { "error": {
"code-taken": "Another group is using this code. Choose different gnomes.",
"limit": "Close one of your groups before opening another.", "limit": "Close one of your groups before opening another.",
"full": "This group is full. Ask for a code for another group.", "full": "This group is full. Ask for a code for another group.",
"rate": "Please wait a minute and try again.", "rate": "Please wait a minute and try again.",
+11 -2
View File
@@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus } from '../services/collaboration.js'; import { createGroup, joinGroup, listGroups, renewCode, closeGroup, verifyOrigin, session, member, invitationStatus, extendInvitation, groupById, groupSummary } from '../services/collaboration.js';
import { acquirePoint, releasePoint, touchPoint, pointLocked } from '../services/point-locks.js'; import { acquirePoint, releasePoint, touchPoint, pointLocked } from '../services/point-locks.js';
import { HttpError } from '../middleware/errors.js'; import { HttpError } from '../middleware/errors.js';
@@ -18,13 +18,16 @@ export function createGroupsRouter(db) {
const account = session(db, req); const account = session(db, req);
if (req.body?.heartbeat) { if (req.body?.heartbeat) {
touchPoint(group, account, req.params.poiId, req.body.token); touchPoint(group, account, req.params.poiId, req.body.token);
extendInvitation(db, group);
return res.status(204).end(); return res.status(204).end();
} }
if (req.body?.release) { if (req.body?.release) {
releasePoint(group, account, req.params.poiId, req.body.token); releasePoint(group, account, req.params.poiId, req.body.token);
return res.json({ released: true }); return res.json({ released: true });
} }
return res.json(acquirePoint(db, group, account, req.params.poiId, req.body)); const lock = acquirePoint(db, group, account, req.params.poiId, req.body);
extendInvitation(db, group);
return res.json(lock);
}); });
router.get('/routes/:routeId/pois/:poiId/lock', (req, res) => { router.get('/routes/:routeId/pois/:poiId/lock', (req, res) => {
const group = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(Number(req.params.routeId)); const group = db.prepare('SELECT * FROM collaboration_groups WHERE route_id = ?').get(Number(req.params.routeId));
@@ -32,6 +35,12 @@ export function createGroupsRouter(db) {
member(db, req, group); member(db, req, group);
res.status(pointLocked(db, group, req.params.poiId) ? 423 : 204).end(); res.status(pointLocked(db, group, req.params.poiId) ? 423 : 204).end();
}); });
router.post('/:id/edit', (req, res) => {
const group = groupById(db, req.params.id);
const access = member(db, req, group);
extendInvitation(db, group);
res.json({ group: groupSummary(db, groupById(db, group.id), access.role) });
});
router.get('/:id/invitation', (req, res) => res.json(invitationStatus(db, req, req.params.id))); router.get('/:id/invitation', (req, res) => res.json(invitationStatus(db, req, req.params.id)));
router.get('/', (req, res) => res.json({ groups: listGroups(db, req) })); router.get('/', (req, res) => res.json({ groups: listGroups(db, req) }));
router.post('/', (req, res) => res.status(201).json(createGroup(db, req, res))); router.post('/', (req, res) => res.status(201).json(createGroup(db, req, res)));
+37 -34
View File
@@ -13,8 +13,7 @@ export const GROUP_SYMBOLS = Object.freeze(['🪵', '🪨', '🏮', '🌼', '
export const GROUP_CODE_LENGTH = 5; export const GROUP_CODE_LENGTH = 5;
export const SESSION_COOKIE = 'wegwichtel_group_session'; export const SESSION_COOKIE = 'wegwichtel_group_session';
const SESSION_SECONDS = 30 * 24 * 60 * 60; const SESSION_SECONDS = 30 * 24 * 60 * 60;
const CODE_SECONDS = 30 * 60; const CODE_SECONDS = 60 * 60;
const invitations = new Map();
const hash = value => crypto.createHash('sha256').update(value).digest('hex'); const hash = value => crypto.createHash('sha256').update(value).digest('hex');
const now = () => Math.floor(Date.now() / 1000); const now = () => Math.floor(Date.now() / 1000);
const joinFailures = new Map(); const joinFailures = new Map();
@@ -65,19 +64,29 @@ export function owner(db, req, id) {
return group; return group;
} }
function issueCode(db, groupId) { function selectedCode(body) {
for (let attempt = 0; attempt < 10; attempt += 1) { const code = invitationCode(body);
const code = Array.from({ length: GROUP_CODE_LENGTH }, () => GROUP_SYMBOLS[crypto.randomInt(GROUP_SYMBOLS.length)]); if (!code) throw new HttpError(400, 'Wähle fünf Wichtel für deinen Code.', { code: 'invalid-code' });
return code;
}
function issueCode(db, groupId, code) {
const digest = hash(code.join('')); const digest = hash(code.join(''));
if (db.prepare('SELECT 1 FROM collaboration_groups WHERE code_hash = ?').get(digest)) continue; const other = db.prepare('SELECT id FROM collaboration_groups WHERE code_hash = ? AND id != ?').get(digest, groupId);
if (other) throw new HttpError(409, 'Dieser Code wird schon verwendet. Wähle andere Wichtel.', { code: 'code-taken' });
const expiresAt = now() + CODE_SECONDS; const expiresAt = now() + CODE_SECONDS;
transaction(db, () => {
db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?') db.prepare('UPDATE collaboration_groups SET code_hash = ?, code_expires_at = ?, active = 1 WHERE id = ?')
.run(digest, expiresAt, groupId); .run(digest, expiresAt, groupId);
const invitation = { code, expiresAt }; db.prepare('INSERT INTO collaboration_invitations (group_id, code) VALUES (?, ?) ON CONFLICT(group_id) DO UPDATE SET code = excluded.code')
invitations.set(groupId, invitation); .run(groupId, JSON.stringify(code));
return invitation; });
} return { code, expiresAt };
throw new HttpError(503, 'Bitte versuche es gleich noch einmal.'); }
export function extendInvitation(db, group) {
if (group.active) db.prepare('UPDATE collaboration_groups SET code_expires_at = ? WHERE id = ? AND active = 1 AND code_hash IS NOT NULL')
.run(now() + CODE_SECONDS, group.id);
} }
function copyFile(relative, routeId, folder) { function copyFile(relative, routeId, folder) {
@@ -108,6 +117,7 @@ function assertGroupQuota(db, sessionHash) {
} }
export function createGroup(db, req, res) { export function createGroup(db, req, res) {
const code = selectedCode(req.body);
const account = session(db, req, res, req.body?.creator_id); const account = session(db, req, res, req.body?.creator_id);
assertGroupQuota(db, account.token_hash); assertGroupQuota(db, account.token_hash);
const source = getRoute(db, Number(req.body?.routeId)); const source = getRoute(db, Number(req.body?.routeId));
@@ -130,7 +140,7 @@ export function createGroup(db, req, res) {
db.prepare("INSERT INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'owner')") db.prepare("INSERT INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'owner')")
.run(id, account.token_hash); .run(id, account.token_hash);
}); });
const invitation = issueCode(db, id); const invitation = issueCode(db, id, code);
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation }; return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
} catch (error) { } catch (error) {
if (route) { if (route) {
@@ -196,14 +206,15 @@ export function joinGroup(db, req, res) {
db.prepare("INSERT OR IGNORE INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'member')") db.prepare("INSERT OR IGNORE INTO collaboration_members (group_id, session_hash, role) VALUES (?, ?, 'member')")
.run(group.id, account.token_hash); .run(group.id, account.token_hash);
joinFailures.delete(key); joinFailures.delete(key);
return groupSummary(db, group, existing?.role || 'member'); extendInvitation(db, group);
return groupSummary(db, groupById(db, group.id), existing?.role || 'member');
} }
export function renewCode(db, req, id) { export function renewCode(db, req, id) {
const group = owner(db, req, id); const group = owner(db, req, id);
if (!group.active) assertGroupQuota(db, session(db, req).token_hash); if (!group.active) assertGroupQuota(db, session(db, req).token_hash);
if (hasPointLocks(group.route_id)) throw new HttpError(423, 'In dieser Gruppe wird gerade eine Station bearbeitet.', { code: 'group-editing' }); if (hasPointLocks(group.route_id)) throw new HttpError(423, 'In dieser Gruppe wird gerade eine Station bearbeitet.', { code: 'group-editing' });
const invitation = issueCode(db, id); const invitation = issueCode(db, id, selectedCode(req.body));
return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation }; return { group: groupSummary(db, groupById(db, id), 'owner'), ...invitation };
} }
@@ -214,32 +225,21 @@ export function closeGroup(db, req, id) {
db.prepare('UPDATE collaboration_groups SET active = 0, code_hash = NULL, code_expires_at = 0 WHERE id = ?').run(id); db.prepare('UPDATE collaboration_groups SET active = 0, code_hash = NULL, code_expires_at = 0 WHERE id = ?').run(id);
db.prepare("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id); db.prepare("DELETE FROM collaboration_members WHERE group_id = ? AND role = 'member'").run(id);
}); });
invitations.delete(id); db.prepare('DELETE FROM collaboration_invitations WHERE group_id = ?').run(id);
return { closed: true }; return { closed: true };
} }
export function invitationStatus(db, req, id) { export function invitationStatus(db, req, id) {
let group = owner(db, req, id); const group = owner(db, req, id);
const busy = hasPointLocks(group.route_id); const invitation = db.prepare('SELECT code FROM collaboration_invitations WHERE group_id = ?').get(id);
if (group.active && group.code_expires_at <= now() && !busy) { return { group: groupSummary(db, group, 'owner'), busy: hasPointLocks(group.route_id),
issueCode(db, id); expired: group.code_expires_at <= now(), expiresAt: group.code_expires_at,
group = groupById(db, id); ...(invitation ? { code: JSON.parse(invitation.code) } : {}) };
}
const invitation = invitations.get(id);
return { group: groupSummary(db, group, 'owner'), busy, expired: group.code_expires_at <= now(), expiresAt: group.code_expires_at,
...(invitation?.expiresAt === group.code_expires_at ? { code: invitation.code } : {}) };
} }
export function maintainCollaboration(db) { export function maintainCollaboration(db) {
const groups = db.prepare(`SELECT g.*, r.activated_at FROM collaboration_groups g JOIN routes r ON r.id = g.route_id const groups = db.prepare("SELECT route_id FROM collaboration_groups g JOIN routes r ON r.id = g.route_id WHERE r.status = 'active'").all();
WHERE r.status = 'active'`).all(); for (const group of groups) hasPointLocks(group.route_id);
for (const group of groups) {
const busy = hasPointLocks(group.route_id);
if (!group.active || group.activated_at) { invitations.delete(group.id); continue; }
const ownerExists = db.prepare(`SELECT 1 FROM collaboration_members m JOIN collaboration_sessions s ON s.token_hash = m.session_hash
WHERE m.group_id = ? AND m.role = 'owner' AND s.expires_at > ?`).get(group.id, now());
if (ownerExists && group.code_expires_at <= now() && !busy) issueCode(db, group.id);
}
} }
export function startCollaborationMaintenance(db) { export function startCollaborationMaintenance(db) {
@@ -287,7 +287,10 @@ export function groupRouteGuard(db) {
writing.add(group.id); writing.add(group.id);
res.setHeader('X-Group-Revision', String(group.revision + 1)); res.setHeader('X-Group-Revision', String(group.revision + 1));
res.once('finish', () => { res.once('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) db.prepare('UPDATE collaboration_groups SET revision = revision + 1 WHERE id = ?').run(group.id); if (res.statusCode >= 200 && res.statusCode < 300) {
db.prepare('UPDATE collaboration_groups SET revision = revision + 1 WHERE id = ?').run(group.id);
extendInvitation(db, group);
}
writing.delete(group.id); writing.delete(group.id);
}); });
res.once('close', () => writing.delete(group.id)); res.once('close', () => writing.delete(group.id));
+62 -23
View File
@@ -1,5 +1,4 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import nodeCrypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -58,7 +57,7 @@ async function fixture(t) {
test('shared draft clones a published route and protects its contents and ownership', async t => { test('shared draft clones a published route and protects its contents and ownership', async t => {
const { db, source, request } = await fixture(t); const { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {}; const host = {}, guest = {}, outsider = {};
const { value: created, response } = await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201); 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.equal(created.code.length, 5);
assert.ok(created.code.every(value => GROUP_SYMBOLS.includes(value))); 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'), /HttpOnly/);
@@ -98,14 +97,14 @@ test('shared draft clones a published route and protects its contents and owners
await request(guest, `/routes/${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' }); 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/0${route.id}`, 'DELETE', null, 403, { 'If-Match': '1' });
await request(guest, `/routes/${route.id}/restore`, 'POST', {}, 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; 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: created.code, creator_id: 'b'.repeat(64) }, 404);
await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) }); await request(outsider, '/groups/join', 'POST', { code: renewed.code, creator_id: 'b'.repeat(64) });
await request(host, `/groups/${created.group.id}`, 'DELETE'); await request(host, `/groups/${created.group.id}`, 'DELETE');
await request(guest, `/routes/${route.id}`, 'GET', null, 403); await request(guest, `/routes/${route.id}`, 'GET', null, 403);
assert.equal((await request(guest, '/groups')).value.groups.length, 0); assert.equal((await request(guest, '/groups')).value.groups.length, 0);
await request(host, `/routes/${route.id}`); await request(host, `/routes/${route.id}`);
const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', {})).value; const reopened = (await request(host, `/groups/${created.group.id}/code`, 'POST', { code: Array(5).fill('🪨') })).value;
assert.equal(reopened.group.active, true); assert.equal(reopened.group.active, true);
await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) }); await request(guest, '/groups/join', 'POST', { code: reopened.code, creator_id: 'a'.repeat(64) });
assert.equal((await request(guest, '/groups')).value.groups.length, 1); assert.equal((await request(guest, '/groups')).value.groups.length, 1);
@@ -115,10 +114,10 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
const { db, source, request } = await fixture(t); const { db, source, request } = await fixture(t);
const host = {}, guest = {}; const host = {}, guest = {};
await request(host, '/groups', 'POST', { routeId: source.id }, 400); 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', { code: Array(5).fill('🐦'), 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', { code: Array(5).fill('🐦'), routeId: source.id, creator_id: 'a'.repeat(64) }, 403, { Origin: 'null' });
await request(host, '/groups', 'POST', { routeId: 900, creator_id: 'a'.repeat(64) }, 404); await request(host, '/groups', 'POST', { code: Array(5).fill('🐦'), 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; 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); 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); 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: created.code, creator_id: 'b'.repeat(64) }, 404);
@@ -136,7 +135,7 @@ test('invitation expiry, request origin, invalid IDs and expired sessions are en
test('overlapping writes are locked until completion and revision changes once', async t => { test('overlapping writes are locked until completion and revision changes once', async t => {
const { db, source, request } = await fixture(t); const { db, source, request } = await fixture(t);
const host = {}; const host = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; 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 guard = groupRouteGuard(db);
const req = { method: 'PUT', path: `/routes/${created.group.route.id}`, headers: { cookie: host.cookie, 'if-match': '0' } }; 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; const res = new EventEmitter(); res.setHeader = () => {}; res.statusCode = 200;
@@ -153,7 +152,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
const { db, source, request } = await fixture(t); const { db, source, request } = await fixture(t);
const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get(); const image = db.prepare('SELECT path FROM poi_images LIMIT 1').get();
fs.rmSync(resolveStoredFile(image.path)); fs.rmSync(resolveStoredFile(image.path));
await request({}, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 404); 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 routes').get().total, 1);
assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0); assert.equal(db.prepare('SELECT COUNT(*) AS total FROM collaboration_groups').get().total, 0);
const client = {}; const client = {};
@@ -166,7 +165,7 @@ test('group copying rolls back a failed media copy and rate limits invalid invit
test('point lock files bind the device and session, require confirmed takeover and reject stale content', async t => { 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 { db, source, request } = await fixture(t);
const host = {}, guest = {}, outsider = {}; const host = {}, guest = {}, outsider = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; 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) }); await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route, poi = route.pois[0]; const route = created.group.route, poi = route.pois[0];
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`; const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
@@ -215,14 +214,14 @@ test('point lock files bind the device and session, require confirmed takeover a
test('different station edits do not conflict through the route revision, and in-flight writes prevent takeover', async t => { 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 { db, source, request } = await fixture(t);
const host = {}, guest = {}; const host = {}, guest = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; 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) }); await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) });
const route = created.group.route; const route = created.group.route;
const second = createPoi(db, route.id, { title: 'Second stop' }); const second = createPoi(db, route.id, { title: 'Second stop' });
const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`; const lockPath = poiId => `/groups/routes/${route.id}/pois/${poiId}/lock`;
const first = route.pois[0]; const first = route.pois[0];
const a = (await request(host, lockPath(first.id), 'POST', {})).value; const a = (await request(host, lockPath(first.id), 'POST', { code: Array(5).fill('🪨') })).value;
const b = (await request(guest, lockPath(second.id), 'POST', {})).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, 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' }); { '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, await request(guest, `/routes/${route.id}/pois/${second.id}`, 'PUT', { title: 'B' }, 200,
@@ -243,9 +242,8 @@ test('different station edits do not conflict through the route revision, and in
test('five-symbol invitations allow repeated motifs and reject other code lengths', async t => { test('five-symbol invitations allow repeated motifs and reject other code lengths', async t => {
const { source, request } = await fixture(t); const { source, request } = await fixture(t);
t.mock.method(nodeCrypto, 'randomInt', () => 5);
const host = {}, guest = {}; const host = {}, guest = {};
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; 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('🐦')); 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(4).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
await request(guest, '/groups/join', 'POST', { code: Array(6).fill('🐦'), creator_id: 'b'.repeat(64) }, 404); await request(guest, '/groups/join', 'POST', { code: Array(6).fill('🐦'), creator_id: 'b'.repeat(64) }, 404);
@@ -254,18 +252,18 @@ test('five-symbol invitations allow repeated motifs and reject other code length
}); });
test('invitations expire after thirty minutes, wait for active locks and rotate after sixty idle minutes', async t => { test('invitations extend on editing, expire after sixty idle minutes and never rotate', async t => {
const { db, source, request } = await fixture(t); const { db, source, request } = await fixture(t);
const host = {}, guest = {}; const host = {}, guest = {};
const started = Date.now(); const started = Date.now();
let clock = started; let clock = started;
t.mock.method(Date, 'now', () => clock); t.mock.method(Date, 'now', () => clock);
const created = (await request(host, '/groups', 'POST', { routeId: source.id, creator_id: 'a'.repeat(64) }, 201)).value; 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) + 1800); assert.equal(created.expiresAt, Math.floor(started / 1000) + 3600);
await request(guest, '/groups/join', 'POST', { code: created.code, creator_id: 'b'.repeat(64) }); 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 group = created.group, route = group.route, poi = route.pois[0];
const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`; const endpoint = `/groups/routes/${route.id}/pois/${poi.id}/lock`;
const lock = (await request(guest, endpoint, 'POST', {})).value; 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 file = path.join(config.storageDir, 'active', 'routes', String(route.id), 'locks', `${poi.id}.json`);
const firstActivity = JSON.parse(fs.readFileSync(file)).updated_at; const firstActivity = JSON.parse(fs.readFileSync(file)).updated_at;
clock += 31 * 60_000; clock += 31 * 60_000;
@@ -273,7 +271,8 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
const held = (await request(host, `/groups/${group.id}/invitation`)).value; const held = (await request(host, `/groups/${group.id}/invitation`)).value;
assert.equal(held.busy, true); assert.equal(held.busy, true);
assert.equal(held.expiresAt, created.expiresAt); assert.equal(held.expiresAt, created.expiresAt);
await request({}, '/groups/join', 'POST', { code: created.code, creator_id: 'c'.repeat(64) }, 404); assert.deepEqual(held.code, created.code);
assert.equal(held.expired, false);
await request(guest, `/groups/${group.id}/invitation`, 'GET', null, 403); await request(guest, `/groups/${group.id}/invitation`, 'GET', null, 403);
await request(host, `/groups/${group.id}/code`, 'POST', {}, 423); await request(host, `/groups/${group.id}/code`, 'POST', {}, 423);
await request(host, endpoint, 'GET', null, 423); await request(host, endpoint, 'GET', null, 423);
@@ -292,7 +291,47 @@ test('invitations expire after thirty minutes, wait for active locks and rotate
const renewed = (await request(host, `/groups/${group.id}/invitation`)).value; const renewed = (await request(host, `/groups/${group.id}/invitation`)).value;
assert.equal(renewed.busy, false); assert.equal(renewed.busy, false);
assert.equal(renewed.code.length, 5); assert.equal(renewed.code.length, 5);
assert.equal(renewed.expiresAt, Math.floor(clock / 1000) + 1800); assert.equal(renewed.expiresAt, Math.floor(started / 1000) + 91 * 60);
await request({}, '/groups/join', 'POST', { code: renewed.code, creator_id: 'c'.repeat(64) }); 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); 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);
});
+8 -1
View File
@@ -1013,10 +1013,13 @@ test('shared drafts use guarded server saves, preserve the creator and retain un
test('sharing a local draft asks before sending and opens a separate shared copy', async () => { test('sharing a local draft asks before sending and opens a separate shared copy', async () => {
const h = buildEditorContext(); const h = buildEditorContext();
const sharedIds = []; const sharedIds = [];
const code = Array(5).fill('🐦');
let chosen = null;
h.context.confirm = () => false; h.context.confirm = () => false;
h.context.Wegwichtel.Groups = { h.context.Wegwichtel.Groups = {
shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; }, shareButton(action) { const button = new FakeElement('', 'button'); button.dataset.editorAction = action; return button; },
async share(id) { sharedIds.push(id); } async chooseCode() { return chosen; },
async share(id, selected) { assert.equal(selected, code); sharedIds.push(id); }
}; };
await loadI18n(h.context); await loadI18n(h.context);
const filename = path.join(root, 'public/js/editor-ui.js'); const filename = path.join(root, 'public/js/editor-ui.js');
@@ -1027,6 +1030,10 @@ test('sharing a local draft asks before sending and opens a separate shared copy
list.dispatchEvent(pointerEvent(item.children[1].children[1])); list.dispatchEvent(pointerEvent(item.children[1].children[1]));
for (let i = 0; i < 4; i += 1) await tick(); for (let i = 0; i < 4; i += 1) await tick();
assert.equal(h.calls.some(call => call.method === 'POST'), false); assert.equal(h.calls.some(call => call.method === 'POST'), false);
chosen = code;
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, 'declining transfer sends nothing');
h.context.confirm = () => true; h.context.confirm = () => true;
list.dispatchEvent(pointerEvent(item.children[1].children[1])); list.dispatchEvent(pointerEvent(item.children[1].children[1]));
list.dispatchEvent(pointerEvent(item.children[1].children[1])); list.dispatchEvent(pointerEvent(item.children[1].children[1]));
+72 -17
View File
@@ -27,17 +27,18 @@ function key(node, value) {
} }
async function harness(loading = false) { async function harness(loading = false) {
const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close', const ids = ['group-join-open', 'editor-group-join', 'editor-group-list', 'group-dialog', 'group-close',
'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route', 'group-entry-title', 'group-entry-dialog', 'group-entry-close', 'group-entry-status', 'group-route',
'group-status', 'group-wheels', 'group-entry', 'group-invitation', 'group-code', 'group-join', 'group-edit', 'group-expiry']; '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 nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget(); const document = new EventTarget();
document.readyState = loading ? 'loading' : 'complete'; document.readyState = loading ? 'loading' : 'complete';
document.getElementById = id => nodes.get(id); document.getElementById = id => nodes.get(id);
document.createElement = () => new Element(); document.createElement = () => new Element();
const calls = [], edits = []; const calls = [], edits = [], timers = [];
const controls = { error: 0, offline: false, confirm: true, active: true, role: 'owner' }; 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 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, const context = { Event, document, Date, Map, Set, Headers, console, crypto, globalThis: null,
setInterval: (callback, delay) => { timers.push({ callback, delay }); },
confirm: () => controls.confirm, matchMedia: () => ({ matches: false }), confirm: () => controls.confirm, matchMedia: () => ({ matches: false }),
Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) }, Wegwichtel: { Config: { apiBase: '/api/' }, LocalRoutes: { creatorId: () => 'a'.repeat(64) },
EditorUi: { async openGroup(route) { edits.push(route.id); } } }, EditorUi: { async openGroup(route) { edits.push(route.id); } } },
@@ -48,7 +49,7 @@ async function harness(loading = false) {
let value; let value;
if (options.method === 'GET') value = { groups: [group()] }; if (options.method === 'GET') value = { groups: [group()] };
else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; } else if (options.method === 'DELETE') { controls.active = false; value = { closed: true }; }
else if (url.endsWith('/join')) value = { group: group() }; else if (url.endsWith('/join') || url.endsWith('/edit')) value = { group: group() };
else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 }; else value = { group: group(), code: Array(5).fill('🐦'), expiresAt: 1800000000 };
return { ok: true, json: async () => value }; return { ok: true, json: async () => value };
} }
@@ -59,7 +60,7 @@ async function harness(loading = false) {
const filename = path.resolve('public/js', module + '.js'); const filename = path.resolve('public/js', module + '.js');
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename }); vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
} }
return { nodes, context, calls, controls, edits, groups: context.Wegwichtel.Groups }; return { nodes, context, calls, controls, edits, timers, groups: context.Wegwichtel.Groups };
} }
test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => { test('group code wheels support arrows, keyboard wrapping, scrolling and joining with ordered Unicode', async () => {
@@ -99,7 +100,7 @@ test('group code wheels support arrows, keyboard wrapping, scrolling and joining
test('sharing displays five 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(); const h = await harness();
await h.groups.share(7); await h.groups.share(7, Array(5).fill('🐦'));
assert.equal(JSON.parse(h.calls[0].body).routeId, 7); assert.equal(JSON.parse(h.calls[0].body).routeId, 7);
assert.equal(h.nodes.get('group-code').children.length, 5); assert.equal(h.nodes.get('group-code').children.length, 5);
assert.equal(h.nodes.get('group-entry-dialog').open, false); assert.equal(h.nodes.get('group-entry-dialog').open, false);
@@ -114,11 +115,10 @@ test('sharing displays five motifs and the owner can renew or close access with
pointer(h.nodes.get('group-edit')); await tick(); pointer(h.nodes.get('group-edit')); await tick();
assert.deepEqual(h.edits, [22]); assert.deepEqual(h.edits, [22]);
let actions = h.nodes.get('editor-group-list').children[0].children[1].children; let actions = h.nodes.get('editor-group-list').children[0].children[1].children;
h.controls.confirm = false;
pointer(actions[2]); await tick(); pointer(actions[2]); await tick();
assert.equal(h.calls.some(call => call.url.endsWith('/code')), false); assert.equal(h.calls.some(call => call.url.endsWith('/code')), false);
h.controls.confirm = true; assert.equal(h.nodes.get('group-entry-dialog').open, true);
pointer(actions[2]); await tick(); pointer(h.nodes.get('group-join')); await tick();
assert.ok(h.calls.some(call => call.url.endsWith('/code'))); assert.ok(h.calls.some(call => call.url.endsWith('/code')));
actions = h.nodes.get('editor-group-list').children[0].children[1].children; actions = h.nodes.get('editor-group-list').children[0].children[1].children;
pointer(actions[1]); await tick(); pointer(actions[1]); await tick();
@@ -192,9 +192,9 @@ test('unsent station changes are listed before network access and discarded only
assert.equal(list.children.length, 0); assert.equal(list.children.length, 0);
}); });
test('sharing opens the actual five-symbol invitation directly without a two-symbol preview', async () => { test('sharing a confirmed code shows the five-symbol invitation', async () => {
const h = await harness(); const h = await harness();
await h.groups.share(7); await h.groups.share(7, Array(5).fill('🐦'));
assert.equal(h.nodes.get('group-dialog').open, true); assert.equal(h.nodes.get('group-dialog').open, true);
assert.equal(h.nodes.get('group-entry-dialog').open, false); assert.equal(h.nodes.get('group-entry-dialog').open, false);
assert.equal(h.nodes.get('group-code').children.length, 5); assert.equal(h.nodes.get('group-code').children.length, 5);
@@ -209,14 +209,14 @@ test('sharing opens the actual five-symbol invitation directly without a two-sym
test('an incomplete invitation is never displayed as a two-symbol group code', async () => { test('an incomplete invitation is never displayed as a two-symbol group code', async () => {
const h = await harness(); const h = await harness();
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { route: { id: 7 } }, code: ['🐦', '🪨'] }) }); h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { route: { id: 7 } }, code: ['🐦', '🪨'] }) });
await assert.rejects(h.groups.share(7)); await assert.rejects(h.groups.share(7, Array(5).fill('🐦')));
assert.equal(h.nodes.get('group-dialog').open, false); assert.equal(h.nodes.get('group-dialog').open, false);
assert.equal(h.nodes.get('group-code').children.length, 0); assert.equal(h.nodes.get('group-code').children.length, 0);
}); });
test('the open invitation refreshes its code automatically and shows a deferred renewal during station editing', async () => { test('the open invitation shows its current expiry without creating a code', async () => {
const h = await harness(); const h = await harness();
await h.groups.share(7); await h.groups.share(7, Array(5).fill('🐦'));
const original = h.context.fetch; const original = h.context.fetch;
h.context.fetch = async (url, options) => { h.context.fetch = async (url, options) => {
if (!url.endsWith('/invitation')) return original(url, options); if (!url.endsWith('/invitation')) return original(url, options);
@@ -224,14 +224,69 @@ test('the open invitation refreshes its code automatically and shows a deferred
busy: true, expired: true, expiresAt: 1800000000 }) }; busy: true, expired: true, expiresAt: 1800000000 }) };
}; };
await h.groups.refreshInvitation(); await h.groups.refreshInvitation();
assert.match(h.nodes.get('group-status').textContent, /Station bearbeitet/); assert.match(h.nodes.get('group-status').textContent, /abgelaufen/);
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 }, role: 'owner' }, 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('🪨') }) }); busy: false, expired: false, expiresAt: 1800001800, code: Array(5).fill('🐦') }) });
await h.groups.refreshInvitation(); await h.groups.refreshInvitation();
assert.equal(h.nodes.get('group-code').children.length, 5); assert.equal(h.nodes.get('group-code').children.length, 5);
assert.match(h.nodes.get('group-code').children[0].src, /🪨/); assert.match(h.nodes.get('group-code').children[0].src, /🐦/);
assert.equal(h.nodes.get('group-status').textContent, ''); assert.equal(h.nodes.get('group-status').textContent, '');
h.nodes.get('group-dialog').close(); h.nodes.get('group-dialog').close();
h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); }; h.context.fetch = async () => { throw new Error('must not fetch a closed invitation'); };
await h.groups.refreshInvitation(); await h.groups.refreshInvitation();
}); });
test('sharing first offers five random editable symbols, sends only the confirmed code and never opens the editor', async () => {
const h = await harness();
const sharing = h.groups.share(7);
assert.equal(h.nodes.get('group-entry-dialog').open, true);
assert.equal(h.calls.length, 0);
assert.equal(h.nodes.get('group-entry-title').textContent, 'Code auswählen');
const columns = h.nodes.get('group-wheels').children;
assert.equal(columns.length, 5);
for (const column of columns) {
const wheel = column.children[1];
assert.ok(Number(wheel.attributes.get('aria-valuenow')) >= 1);
assert.equal(wheel.scrollTop, (Number(wheel.attributes.get('aria-valuenow')) - 1) * 96);
key(wheel, 'Home');
}
pointer(columns[2].children[1].children[5]);
pointer(h.nodes.get('group-join'));
await sharing;
assert.deepEqual(JSON.parse(h.calls[0].body).code, ['🪵', '🪵', '🐦', '🪵', '🪵']);
assert.equal(h.nodes.get('group-dialog').open, true);
assert.deepEqual(h.edits, []);
});
test('cancelling code selection sends no request and a late double tap cannot join a group', async () => {
const h = await harness();
const sharing = h.groups.share(7);
pointer(h.nodes.get('group-entry-close'));
await sharing;
pointer(h.nodes.get('group-join'));
await tick();
assert.equal(h.calls.length, 0);
assert.deepEqual(h.edits, []);
});
test('the countdown decreases, reflects extended expiry and shows expiration without changing the code', async () => {
const h = await harness();
let clock = 1799996400000;
h.context.Date = class extends Date { static now() { return clock; } };
await h.groups.share(7, Array(5).fill('🐦'));
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
const timer = h.timers.find(timer => timer.delay === 1000);
clock += 45_000;
timer.callback();
assert.match(h.nodes.get('group-expiry').textContent, /59 Min. 15 Sek./);
const code = h.nodes.get('group-code').children.map(image => image.src);
h.context.fetch = async () => ({ ok: true, json: async () => ({ group: { id: 'group-id', route: { id: 22 } },
code: Array(5).fill('🐦'), expiresAt: clock / 1000 + 3600 }) });
await h.groups.refreshInvitation();
assert.match(h.nodes.get('group-expiry').textContent, /60 Min. 00 Sek./);
clock += 3600_000;
timer.callback();
assert.match(h.nodes.get('group-expiry').textContent, /abgelaufen/);
assert.deepEqual(h.nodes.get('group-code').children.map(image => image.src), code);
});
+4
View File
@@ -45,6 +45,10 @@ const EXPECTED_SCHEMA = Object.freeze({
revision: { type: 'INTEGER', notnull: 1 }, revision: { type: 'INTEGER', notnull: 1 },
created_at: { type: 'TEXT', notnull: 1 } created_at: { type: 'TEXT', notnull: 1 }
}, },
collaboration_invitations: {
group_id: { type: 'TEXT', notnull: 1, pk: 1 },
code: { type: 'TEXT', notnull: 1 }
},
collaboration_members: { collaboration_members: {
group_id: { type: 'TEXT', notnull: 1, pk: 1 }, group_id: { type: 'TEXT', notnull: 1, pk: 1 },
session_hash: { type: 'TEXT', notnull: 1, pk: 2 }, session_hash: { type: 'TEXT', notnull: 1, pk: 2 },