Improved import security, added browser tests
Some checks failed
Tests et déploiement / deploy (push) Has been cancelled
Tests et déploiement / validate (push) Has been cancelled

This commit is contained in:
2026-10-08 21:34:58 +02:00
parent 5cf19bb8ac
commit b0aa5acf4b
28 changed files with 793 additions and 99 deletions

84
src/lib/backup-safety.js Normal file
View File

@@ -0,0 +1,84 @@
import { db, exportBackup, readSettings } from './db.js';
import { parseBackup } from './backup.js';
import { DAY, localDateKey } from './time.js';
export const BACKUP_REMINDER_DAYS = 7;
export async function readBackupStatus() {
return (await db.settings.get('backup-status'))?.value ?? {};
}
/** Verify the complete downloaded file, not just its timestamp or row counts. */
async function digest(text) {
const bytes = new TextEncoder().encode(text);
const hash = await crypto.subtle.digest('SHA-256', bytes);
return [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function prepareVerifiedExport(now = Date.now()) {
const payload = await exportBackup();
const text = JSON.stringify(payload, null, 2);
// A backup must pass the same reader used for restoration before we offer it.
parseBackup(text);
const checksum = await digest(text);
await db.transaction('rw', db.settings, async () => {
const current = await readBackupStatus();
await db.settings.put({ key: 'backup-status', value: {
...current, preparedAt: now, exportedAt: payload.exportedAt, checksum,
} });
});
return { payload, text };
}
export async function verifyExportFile(text, now = Date.now()) {
const payload = parseBackup(text);
const checksum = await digest(text);
return db.transaction('rw', db.settings, async () => {
const current = await readBackupStatus();
if (!current.checksum || checksum !== current.checksum) {
throw new Error('Ce fichier ne correspond pas au dernier export préparé sur cet appareil. Exportez les données puis choisissez ce nouveau fichier.');
}
const next = { ...current, verifiedAt: now, verifiedExportedAt: current.exportedAt };
await db.settings.put({ key: 'backup-status', value: next });
return { status: next, days: payload.dayCount, entries: payload.entryCount };
});
}
export function backupReminderDue(status, now = Date.now()) {
const verified = Date.parse(status.verifiedExportedAt ?? '');
return !Number.isFinite(verified) || now - verified >= BACKUP_REMINDER_DAYS * DAY;
}
export function purgeCandidates(days, retentionDays, now = Date.now()) {
const closed = days.filter(day => day.status === 'closed').sort((a, b) => b.date.localeCompare(a.date));
const cutoff = localDateKey(now - retentionDays * DAY);
return retentionDays > 0 ? closed.filter(day => day.date < cutoff && day.date !== closed[0]?.date) : [];
}
/** Delete only the reviewed, unchanged dates, preserving a snapshot under one lock. */
export function purgeReviewedDays(reviewed, retentionDays, now = Date.now()) {
return db.transaction('rw', db.days, db.entries, db.settings, async () => {
const stored = await readSettings();
if (stored?.retentionDays != null && stored.retentionDays !== retentionDays) {
throw new Error('La durée de conservation a changé. Fermez puis ouvrez la confirmation.');
}
const days = await db.days.toArray();
const entries = await db.entries.toArray();
const eligible = new Set(purgeCandidates(days, retentionDays, now).map(day => day.date));
for (const original of reviewed) {
const current = days.find(day => day.date === original.day.date);
const own = entries.filter(entry => entry.date === original.day.date).sort((a, b) => a.seq - b.seq);
if (!eligible.has(original.day.date) || JSON.stringify(current) !== JSON.stringify(original.day) || JSON.stringify(own) !== JSON.stringify(original.entries)) {
throw new Error('Une journée à purger a changé. Fermez puis ouvrez la confirmation.');
}
}
if (!reviewed.length) return 0;
const backup = await exportBackup();
parseBackup(JSON.stringify(backup));
await db.settings.put({ key: 'before-purge', value: backup });
const dates = reviewed.map(item => item.day.date);
await db.entries.where('date').anyOf(dates).delete();
await db.days.bulkDelete(dates);
return dates.length;
});
}

View File

@@ -0,0 +1,71 @@
import 'fake-indexeddb/auto';
import { beforeEach, expect, it, vi } from 'vitest';
import { db, getDay, getEntries, importBackup } from './db.js';
import { backupReminderDue, prepareVerifiedExport, purgeCandidates, purgeReviewedDays, readBackupStatus, verifyExportFile } from './backup-safety.js';
import { parseBackup } from './backup.js';
import { atLocalTime, DAY } from './time.js';
const now = atLocalTime('2026-10-08', '18:00');
const record = date => ({ date, status: 'closed', startedTs: atLocalTime(date,'08:00'), endedTs: atLocalTime(date,'17:00'), plannedStartTs: atLocalTime(date,'08:00'), plannedEndTs: atLocalTime(date,'17:00'), lunchType: 'panier' });
async function install(date) {
const day = record(date); await db.days.add(day);
await db.entries.add({ date, seq:0, kind:'work', task:'Chantier de test', client:null, startTs:day.startedTs,endTs:day.endedTs });
}
async function reviewed(date) { return { day: await getDay(date), entries: await getEntries(date) }; }
beforeEach(async () => { await db.days.clear(); await db.entries.clear(); await db.settings.clear(); });
it('only verifies the complete exported file, without altering the history', async () => {
await install('2026-10-08');
const prepared = await prepareVerifiedExport(now);
const before = await getEntries('2026-10-08');
await verifyExportFile(prepared.text,now);
expect((await readBackupStatus()).verifiedExportedAt).toBe(prepared.payload.exportedAt);
expect(await getEntries('2026-10-08')).toEqual(before);
const corrupted = JSON.parse(prepared.text); corrupted.entries[0].task = 'Changed';
await expect(verifyExportFile(JSON.stringify(corrupted))).rejects.toThrow(/ne correspond pas/);
});
it('reminds after seven days from the verified export, rather than the day it was checked', () => {
expect(backupReminderDue({},now)).toBe(true);
expect(backupReminderDue({verifiedExportedAt:new Date(now-6*DAY).toISOString()},now)).toBe(false);
expect(backupReminderDue({verifiedExportedAt:new Date(now-7*DAY).toISOString(),verifiedAt:now},now)).toBe(true);
});
it('preserves an importable snapshot and purges only the dates reviewed', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await install('2025-02-01'); // another tab imports an eligible day after review
expect(await purgeReviewedDays([old],30,now)).toBe(1);
expect(await getDay('2025-01-01')).toBeUndefined();
expect(await getDay('2025-02-01')).toBeDefined();
const payload = parseBackup(JSON.stringify((await db.settings.get('before-purge')).value));
await importBackup(payload,'merge');
expect(await getEntries('2025-01-01')).toMatchObject(old.entries.map(({id,...rest})=>rest));
});
it('rejects a changed reviewed day, leaving its slots and previous backup intact', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await db.entries.update(old.entries[0].id,{task:'Correction dans un autre onglet'});
await expect(purgeReviewedDays([old],30,now)).rejects.toThrow(/a changé/);
expect(await getDay('2025-01-01')).toBeDefined();
expect(await db.settings.get('before-purge')).toBeUndefined();
});
it('rolls back the saved snapshot and deletion on a database failure', async () => {
await install('2025-01-01'); await install('2026-10-08');
const old = await reviewed('2025-01-01');
await db.settings.put({key:'before-purge',value:'existing backup'});
const spy = vi.spyOn(db.days,'bulkDelete').mockRejectedValue(new Error('Storage failure'));
try { await expect(purgeReviewedDays([old],30,now)).rejects.toThrow('Storage failure'); }
finally { spy.mockRestore(); }
expect(await getEntries('2025-01-01')).toEqual(old.entries);
expect((await db.settings.get('before-purge')).value).toBe('existing backup');
});
it('always preserves the last closed day and active days', () => {
const old = record('2025-01-01');
expect(purgeCandidates([old],30,now)).toEqual([]);
expect(purgeCandidates([old,{...record('2026-10-08'),status:'active'}],30,now)).toEqual([]);
});
it('rejects a retention change from another tab before writing a backup', async () => {
await install('2025-01-01'); await install('2026-10-08');
await db.settings.put({key:'app',value:{retentionDays:720}});
await expect(purgeReviewedDays([await reviewed('2025-01-01')],30,now)).rejects.toThrow(/conservation a changé/);
expect(await getDay('2025-01-01')).toBeDefined();
});

View File

@@ -0,0 +1,6 @@
import { readBackupStatus } from './backup-safety.js';
export const backupState = $state({ status: {}, loaded: false });
export async function loadBackupStatus() {
backupState.status = await readBackupStatus();
backupState.loaded = true;
}

View File

@@ -6,7 +6,7 @@ import { buildStats } from './stats.js';
import { buildBackup, parseBackup } from './backup.js';
import { buildModel } from './predict.js';
import { automateDay } from './automation.js';
import { insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { insertGapEntry, insertRecordedEntry, removeRecordedEntry, saveRecordedEntry } from './editing.js';
import { DEFAULT_SETTINGS } from './settings.svelte.js';
import { atLocalTime, HOUR, MINUTE } from './time.js';
const date = '2026-10-08';
@@ -113,3 +113,23 @@ describe('preserving imported gaps while editing', () => {
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[1].startTs).toBe(at('13:00'));
});
});
it('refuses to resume a closure that was corrected after confirmation opened', async () => {
await install([slot(0,'work','08:00','17:00')]);
const original = (await getEntries(date))[0];
await saveRecordedEntry(original,{...original,endTs:at('16:30')},at('18:00'));
await expect(reopenDay(date,at('18:00'),'break',{date,endedTs:at('17:00'),last:original})).rejects.toThrow(/a changé/);
expect((await getDay(date)).status).toBe('closed');
expect(await getEntries(date)).toHaveLength(1);
});
it('records a pause within a gap without changing either work neighbour', async () => {
await install([slot(0,'work','08:00','11:00'),slot(1,'work','13:00','17:00')]);
const [previous, following] = await getEntries(date);
await insertGapEntry(previous, following, slot(0,'break','11:30','12:30'), at('18:00'));
const rows = await getEntries(date);
expect(rows.map(row => row.kind)).toEqual(['work','break','work']);
expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00'));
expect(buildStats([day],rows).quality.uncoveredMs).toBe(HOUR);
await expect(insertGapEntry(previous,following,slot(0,'break','11:30','12:30'),at('18:00'))).rejects.toThrow(/a changé/);
});

View File

@@ -21,7 +21,8 @@ import {
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { automateDay, manualAdvance, previousContext } from './automation.js';
import { loadSettings, settings } from './settings.svelte.js';
import { loadBackupStatus } from './backup-state.svelte.js';
import { applyTheme, loadSettings, settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({
@@ -36,6 +37,7 @@ export const dayState = $state({
historyEntries: [],
todayRecord: null,
previousWork: null,
lastKnownWork: null,
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
@@ -70,7 +72,7 @@ export function reconcile(now = Date.now()) {
async function reconcileDay(now) {
// A settings change in another tab must also stop this tab's clock actions.
const persisted = await readSettings();
if (persisted) Object.assign(settings, persisted);
if (persisted) { Object.assign(settings, persisted); applyTheme(); }
dayState.today = localDateKey(now);
const active = await getActiveDay();
@@ -139,7 +141,9 @@ export async function refresh(now = Date.now()) {
const entriesFor = (day) => day ? entries.filter((e) => e.date === day.date).sort((a, b) => a.seq - b.seq) : [];
dayState.today = localDateKey(now);
dayState.todayRecord = days.find((d) => d.date === dayState.today) ?? null;
dayState.previousWork = previousContext(days, entries, active?.date ?? dayState.today).previousWork;
const previous = previousContext(days, entries, active?.date ?? dayState.today);
dayState.previousWork = previous.previousWork;
dayState.lastKnownWork = previous.lastKnownWork;
dayState.day = active ?? null;
dayState.entries = entriesFor(active);
dayState.lastClosed = lastClosed ?? null;
@@ -229,7 +233,7 @@ export async function applyImport(payload, mode) {
// The guard above prevents subsequent ticks/focus events during the import.
await Promise.allSettled([...pendingReconciliations]);
const result = await importBackup(payload, mode);
if (mode === 'replace') await loadSettings();
if (mode === 'replace') { await loadSettings(); await loadBackupStatus(); }
// Do not immediately purge old imported days or create a new working day.
// Retention is explicitly confirmed in settings.
dayState.today = localDateKey();
@@ -259,10 +263,11 @@ export async function keepWorking(now = Date.now()) {
}
/** Undo a premature end-of-day, continuing the last task without a gap. */
export async function reopenToday(now = Date.now(), mode = 'continue') {
export async function reopenToday(now = Date.now(), mode = 'continue', expected = null) {
if (dayState.importing) return;
await reopenDay(localDateKey(now), now, mode);
const reopened = await reopenDay(expected?.date ?? localDateKey(now), now, mode, expected);
await refresh(now);
return reopened;
}
/* ---------------------------------------------------------------- predictions */
@@ -277,7 +282,7 @@ export function predictNext(now = Date.now()) {
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running();
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.previousWork;
const lastWork = [...dayState.entries].reverse().find((e) => e.kind === 'work') ?? dayState.lastKnownWork;
const tasks = suggestTasks(model, {
prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0,

View File

@@ -1,8 +1,8 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanupOldDays, closeAndStart, closeDay, createDay, db, getDay, getEntries, reopenDay } from './db.js';
import { advance, dayState, endDay, keepWorking, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, updateSettings } from './settings.svelte.js';
import { advance, dayState, endDay, keepWorking, predictNext, reconcile, refresh, reopenToday, setLunch, startDayNow, startLunch } from './day.svelte.js';
import { DEFAULT_SETTINGS, settings, settingsError, setWorkingDay, updateSettings } from './settings.svelte.js';
import { atLocalTime, HOUR } from './time.js';
import { buildResume } from './resume.js';
@@ -244,3 +244,22 @@ describe('settings persistence across tabs', () => {
expect(settingsError.message).toContain('write failed');
});
});
it('preserves weekdays changed in another tab when toggling one weekday', async () => {
settings.workdays = [1];
await db.settings.put({key:'app',value:{...DEFAULT_SETTINGS,workdays:[1,3]}});
await setWorkingDay(2,true);
expect((await db.settings.get('app')).value.workdays).toEqual([1,2,3]);
});
it('resumes a pause using the last known work even when the previous day only has transport', async () => {
const oldDate = '2026-09-04', recentDate = '2026-09-05';
await db.days.bulkAdd([oldDate,recentDate].map(date => ({...makeDay(date),status:'closed',endedTs:at('17:00',date)})));
await db.entries.bulkAdd([
{date:oldDate,seq:0,kind:'work',task:'Pose ancienne',client:'Client ancien',startTs:at('08:00',oldDate),endTs:at('17:00',oldDate)},
{date:recentDate,seq:0,...first,startTs:at('08:00',recentDate),endTs:at('17:00',recentDate)},
]);
await startDayNow(at('07:30'));
await advance({kind:'break',task:'Pause'},at('08:00'));
expect(predictNext(at('08:00'))).toMatchObject({task:'Pose ancienne',client:'Client ancien'});
});

View File

@@ -121,7 +121,7 @@ export function closeDay(date, ts, stale = false) {
}
/** Confirmed same-day reopening; status and all resumed slots commit together. */
export function reopenDay(date, now, mode = 'continue') {
export function reopenDay(date, now, mode = 'continue', expected = null) {
return db.transaction('rw', db.days, db.entries, async () => {
const day = await getDay(date);
if (!['continue', 'break'].includes(mode)) throw new Error('Choix de reprise invalide.');
@@ -129,6 +129,10 @@ export function reopenDay(date, now, mode = 'continue') {
const entries = await getEntries(date);
const last = entries.at(-1);
if (!last) return false;
if (expected && (day.endedTs !== expected.endedTs ||
['id', 'kind', 'task', 'client', 'startTs', 'endTs'].some(key => last[key] !== expected.last[key]))) {
throw new Error('La clôture ou le dernier poste a changé. Fermez puis ouvrez la confirmation de reprise.');
}
const boundary = day.endedTs;
if (!Number.isFinite(boundary) || now < boundary) return false;
if (mode === 'continue' && ['work', 'travel'].includes(last.kind)) {

View File

@@ -1,13 +1,28 @@
/** Focus ownership, keyboard trap and restoration for modal panels. */
/** Modal focus ownership follows visual stacking, including nested pickers. */
const hidden = new WeakMap();
function topDialog() {
return [...document.querySelectorAll('[role="dialog"]')]
.filter(node => node.isConnected)
.sort((a, b) => (Number(getComputedStyle(a).zIndex) || 0) - (Number(getComputedStyle(b).zIndex) || 0)).at(-1);
}
export function modalFocus(node) {
const origin = document.activeElement;
const controls = () => [...node.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
.filter(el => !el.disabled && el.getClientRects().length);
queueMicrotask(() => (controls()[0] ?? node).focus());
.filter(el => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length);
const blocked = [];
// Hide every branch outside this dialog from keyboard and assistive technology.
for (let branch = node; branch.parentElement; branch = branch.parentElement) {
for (const sibling of branch.parentElement.children) {
if (sibling === branch || !(sibling instanceof HTMLElement)) continue;
const record = hidden.get(sibling) ?? { count: 0, inert: sibling.inert };
record.count++; hidden.set(sibling, record); sibling.inert = true; blocked.push(sibling);
}
}
const focusFirst = () => (controls()[0] ?? node).focus();
queueMicrotask(() => { if (node.isConnected && topDialog() === node) focusFirst(); });
function trap(event) {
if (event.key !== 'Tab') return;
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
if (dialogs.at(-1) !== node) return;
if (event.key !== 'Tab' || topDialog() !== node) return;
const list = controls(), first = list[0], last = list.at(-1);
if (!first) { event.preventDefault(); node.focus(); return; }
if (event.shiftKey && (document.activeElement === first || !node.contains(document.activeElement))) {
@@ -16,6 +31,18 @@ export function modalFocus(node) {
event.preventDefault(); first.focus();
}
}
function contain(event) {
if (topDialog() === node && !node.contains(event.target)) focusFirst();
}
document.addEventListener('keydown', trap);
return { destroy() { document.removeEventListener('keydown', trap); if (origin instanceof HTMLElement && origin.isConnected) origin.focus(); } };
document.addEventListener('focusin', contain);
return { destroy() {
document.removeEventListener('keydown', trap);
document.removeEventListener('focusin', contain);
for (const element of blocked) {
const record = hidden.get(element);
if (--record.count === 0) { element.inert = record.inert; hidden.delete(element); }
}
if (origin instanceof HTMLElement && origin.isConnected && !origin.closest('[inert]')) origin.focus();
} };
}

View File

@@ -103,3 +103,23 @@ export function removeRecordedEntry(original) {
}
});
}
/** Record an explicit slot inside an imported empty interval without filling its remnants. */
export function insertGapEntry(previousOriginal, followingOriginal, values, now = Date.now()) {
return db.transaction('rw', db.days, db.entries, async () => {
const { entry: previous, entries } = await readOriginal(previousOriginal);
const { entry: following } = await readOriginal(followingOriginal);
const index = entries.findIndex(entry => entry.id === previous.id);
if (following.date !== previous.date || entries[index + 1]?.id !== following.id || previous.endTs == null) throw conflict();
validateTimes(values.startTs, values.endTs, now);
if (values.startTs < previous.endTs || values.endTs > following.startTs) {
throw new Error('Le poste ajouté doit rester dans l’intervalle sans poste.');
}
for (const later of entries.slice(index + 1)) await db.entries.update(later.id, { seq: later.seq + 1 });
const id = await db.entries.add({
date: previous.date, seq: previous.seq + 1, ...content(values), startTs: values.startTs, endTs: values.endTs,
});
await saveLunch(previous.date, values);
return id;
});
}

View File

@@ -9,18 +9,23 @@
const ROUTES = ['resume', 'today', 'settings', 'stats', 'details', 'automations', 'non-working'];
function fromHash() {
const h = location.hash.replace(/^#\/?/, '');
return ROUTES.includes(h) ? h : null;
const [path, query = ''] = location.hash.replace(/^#\/?/, '').split('?');
const date = new URLSearchParams(query).get('date');
return { route: ROUTES.includes(path) ? path : null,
selectedDate: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : '' };
}
export const router = $state({ route: fromHash(), selectedDate: '' });
export const router = $state(fromHash());
window.addEventListener('hashchange', () => {
router.route = fromHash();
const next = fromHash();
router.route = next.route;
if (next.selectedDate) router.selectedDate = next.selectedDate;
});
export function go(route) {
location.hash = route ? `#/${route}` : '';
const date = ['resume', 'details'].includes(route) && router.selectedDate ? `?date=${router.selectedDate}` : '';
location.hash = route ? `#/${route}${date}` : '';
}
export function back() {

View File

@@ -101,3 +101,23 @@ export async function changeNonWorkingDate(original, replacement) {
});
settings.nonWorkingDates = nextDates;
}
/** Update one weekday against the persisted list, preserving other tabs' choices. */
export async function setWorkingDay(value, enabled) {
try {
parseSettings({ workdays: [value] });
const persisted = await db.transaction('rw', db.settings, async () => {
const { loaded, ...base } = $state.snapshot(settings);
const stored = { ...base, ...await readSettings() };
const days = new Set(stored.workdays);
if (enabled) days.add(value); else days.delete(value);
const next = { ...stored, workdays: [...days].sort() };
await writeSettings(next);
return next;
});
Object.assign(settings, persisted); applyTheme(); settingsError.message = null;
} catch (error) {
settingsError.message = `Enregistrement impossible : ${error.message}`;
try { await loadSettings(); } catch { /* Keep the last displayed state. */ }
}
}