diff --git a/src/app/(app)/account/account-client.tsx b/src/app/(app)/account/account-client.tsx index 6b25b23..e227f0a 100644 --- a/src/app/(app)/account/account-client.tsx +++ b/src/app/(app)/account/account-client.tsx @@ -7,6 +7,11 @@ import { changePassword, startLogin } from "@/lib/auth/client-prelude"; import { HIBP_ENABLED, pwnedPasswordCount } from "@/lib/auth/pwned"; import * as deviceUnlock from "@/lib/idb/device-unlock"; import { useKeyBundle } from "@/stores/key-bundle"; +import { + buildAccountExport, + downloadAccountExport, + type RawAccountExport, +} from "@/lib/export/account-export"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; @@ -62,6 +67,7 @@ export function AccountClient({ + ); @@ -344,6 +350,94 @@ function DataSection() { ); } +/** + * DSGVO Art. 20 — decrypted, portable export of every board in every + * workspace. The sibling section above ships what the *server* holds, which is + * ciphertext; this one is the readable counterpart, and it can only exist on + * the client because the server has no keys. + */ +function BulkBoardExportSection() { + const userKeyPair = useKeyBundle((s) => s.userKeyPair); + const hydrated = useKeyBundle((s) => s.hydrated); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + + const locked = hydrated && !userKeyPair; + + async function exportAllBoards() { + if (!userKeyPair) return; + const ok = window.confirm( + "This downloads a plaintext JSON of every board you can read: cards, " + + "comments, checklists and custom-field values across all workspaces. " + + "Keep the file safe, its contents are no longer end-to-end encrypted.\n\nContinue?", + ); + if (!ok) return; + setBusy(true); + setError(null); + setProgress(null); + try { + const res = await fetch("/api/me/export"); + if (!res.ok) { + setError("Could not fetch your data."); + return; + } + const raw = (await res.json()) as RawAccountExport; + const snapshot = await buildAccountExport({ + raw, + userKeyPair, + exportedAt: new Date().toISOString(), + onProgress: (done, total) => setProgress({ done, total }), + }); + downloadAccountExport(snapshot); + if (snapshot.skipped.length > 0) { + setError( + `${snapshot.skipped.length} board(s) could not be decrypted and are listed under "skipped" in the file.`, + ); + } + } catch { + setError("Could not build the export."); + } finally { + setBusy(false); + setProgress(null); + } + } + + return ( + +

+ Download every board from every workspace as one readable JSON file. Decryption happens in + this browser, so the server never sees the plaintext. +

+ {locked ? ( +

+ Your vault is locked. Open a board once to unlock it, then come back. +

+ ) : ( +
+ +
+ )} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + /** DSGVO Art. 17 — crypto-shred + scheduled hard delete (#46). */ function DangerSection() { const router = useRouter(); diff --git a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx index 97e2892..6d518c0 100644 --- a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx +++ b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/board-client.tsx @@ -14,7 +14,7 @@ import { useKeyBundle } from "@/stores/key-bundle"; import { useBoardLive } from "@/hooks/use-board-live"; import { useBoardPresence } from "@/hooks/use-board-presence"; import { loadBoardSnapshot, saveBoardSnapshot } from "@/lib/idb/board-cache"; -import { queuedFetch } from "@/lib/offline/sync"; +import { queueOp, queuedFetch } from "@/lib/offline/sync"; import { downloadCsvExport, downloadExport, @@ -836,6 +836,15 @@ export function BoardClient({ const t = templates.find((x) => x.id === templateId); if (!t) return; + // Every id in the chain is minted up front (ADR 0023). That is what makes + // the whole template applicable offline: previously each POST had to wait + // for the previous response to learn the id it needed, so nothing could be + // queued. With the ids known in advance the ops are independent, and + // flushOutbox replays them oldest-first, so a child never lands before its + // parent. Every create endpoint in the chain upserts idempotently, so a + // partial replay is safe to repeat. + const newCardId = crypto.randomUUID(); + const cardMeta: CardMeta = { title: t.body.title }; if (t.body.description) cardMeta.description = t.body.description; const env = encryptToEnvelope({ @@ -843,89 +852,123 @@ export function BoardClient({ payload: cardMeta, bindTo: "card:new", }); - const res = await fetch(`/api/columns/${columnId}/cards`, { + const { queued, res } = await queuedFetch(currentUserId, { + boardId, + label: `Create card from template "${t.body.title}"`, method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_content: env.serialised }), + url: `/api/columns/${columnId}/cards`, + body: { id: newCardId, enc_content: env.serialised }, }); - if (!res.ok) return; - const createdCard = (await res.json()) as ApiCard; + if (!queued && !(res && res.ok)) return; - // Append optimistically with zero checklist items; we'll patch the - // progress after all the items have been posted. + const createdCard = !queued && res ? ((await res.json()) as ApiCard) : null; + + // Once any link of the chain is queued, every later one must be queued + // too, even if the browser still reports itself online. Otherwise a + // transient failure on the card POST would queue it while the checklist + // POST fired directly and 404'd, because the card does not exist server + // side yet. queuedFetch's per-card ordering guard cannot cover this: the + // card create posts to /api/columns//cards, which carries no card id + // in its path, so it shares no ordering key with its children. + let chainQueued = queued; + async function chainSend(op: { + label: string; + method: "POST" | "PATCH"; + url: string; + body: unknown; + }): Promise { + if (chainQueued) { + await queueOp(currentUserId, { boardId, ...op }); + return; + } + const r = await queuedFetch(currentUserId, { boardId, ...op }); + if (r.queued) chainQueued = true; + } + + // Count from the template itself rather than from successful responses: + // offline there are no responses, and the counts have to match what the + // queued ops will create. + let total = 0; + let done = 0; + for (const cl of t.body.checklists ?? []) { + for (const it of cl.items ?? []) { + total += 1; + if (it.done) done += 1; + } + } + + // Append optimistically. Offline we synthesize a tail position the same + // way createCard does, so ordering stays stable until the real row lands. + const tailPos = cards + .filter((c) => c.columnId === columnId) + .reduce((m, c) => Math.max(m, c.position), 0); setCards((prev) => [ ...prev, { - id: createdCard.id, - columnId: createdCard.columnId, + id: createdCard?.id ?? newCardId, + columnId: createdCard?.columnId ?? columnId, title: t.body.title, description: t.body.description, - position: createdCard.position, - dueAt: createdCard.dueAt, - startAt: createdCard.startAt, - archived: createdCard.archived, - labelIds: createdCard.labelIds, - assigneeIds: createdCard.assigneeIds, - milestoneId: createdCard.milestoneId, - parentId: createdCard.parentId, - checklistProgress: { total: 0, done: 0 }, + position: createdCard?.position ?? tailPos + 1000, + dueAt: createdCard?.dueAt ?? null, + startAt: createdCard?.startAt ?? null, + archived: createdCard?.archived ?? false, + labelIds: createdCard?.labelIds ?? [], + assigneeIds: createdCard?.assigneeIds ?? [], + milestoneId: createdCard?.milestoneId ?? null, + parentId: createdCard?.parentId ?? null, + checklistProgress: { total, done }, }, ]); - let total = 0; - let done = 0; for (const cl of t.body.checklists ?? []) { + const checklistId = crypto.randomUUID(); const titleEnv = encryptToEnvelope({ key: boardKey, payload: { title: cl.title }, bindTo: "checklist:new", }); - const clRes = await fetch(`/api/cards/${createdCard.id}/checklists`, { + await chainSend({ + label: `Add checklist "${cl.title}"`, method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_title: titleEnv.serialised }), + url: `/api/cards/${newCardId}/checklists`, + body: { id: checklistId, enc_title: titleEnv.serialised }, }); - if (!clRes.ok) continue; - const createdCl = (await clRes.json()) as { id: string }; for (const it of cl.items ?? []) { + const itemId = crypto.randomUUID(); const itEnv = encryptToEnvelope({ key: boardKey, payload: { content: it.content }, bindTo: "checklist-item:new", }); - const itemRes = await fetch(`/api/checklists/${createdCl.id}/items`, { + await chainSend({ + label: "Add checklist item", method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enc_content: itEnv.serialised }), + url: `/api/checklists/${checklistId}/items`, + body: { id: itemId, enc_content: itEnv.serialised }, }); - if (!itemRes.ok) continue; - total += 1; if (it.done) { - // Persist initial `done` state for items that were ticked - // in the template. - const item = (await itemRes.json()) as { id: string }; - done += 1; - await fetch(`/api/checklist-items/${item.id}`, { + // Persist the initial `done` state for items ticked in the template. + await chainSend({ + label: "Tick checklist item", method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ done: true }), + url: `/api/checklist-items/${itemId}`, + body: { done: true }, }); } } } - if (total > 0) { - setCards((prev) => - prev.map((c) => - c.id === createdCard.id ? { ...c, checklistProgress: { total, done } } : c, - ), - ); + + bumpActivity(); + // Automation runs on online clients only; skip the trigger emit offline. + if (!queued) { + emitAutomationTrigger({ + type: "card_created", + cardId: newCardId, + columnId, + }); } - emitAutomationTrigger({ - type: "card_created", - cardId: createdCard.id, - columnId: createdCard.columnId, - }); } async function deleteTemplate(templateId: string): Promise { diff --git a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx index bc8083f..a1ea2ed 100644 --- a/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx +++ b/src/app/(app)/workspaces/[wsId]/boards/[boardId]/kanban-view.tsx @@ -303,6 +303,7 @@ export function KanbanView({ onCreateFromTemplate(col.id, t.id); setTemplatePickerFor(null); }} + aria-label={`Create card from template ${t.body.title}`} className="flex-1 truncate rounded px-2 py-1 text-left text-xs hover:bg-[color:var(--color-card)]" title={t.body.description ?? ""} > diff --git a/src/app/api/cards/[id]/checklists/route.ts b/src/app/api/cards/[id]/checklists/route.ts index cc3f5f0..fbf0c69 100644 --- a/src/app/api/cards/[id]/checklists/route.ts +++ b/src/app/api/cards/[id]/checklists/route.ts @@ -13,6 +13,20 @@ interface RouteContext { } const createSchema = z.object({ + /** + * Optional client-generated checklist id for offline create (ADR 0023). + * Needed because creating a card from a template is a POST chain in which + * each step needs the previous step's id: minting the ids up front makes + * the whole chain queueable. A replay of the same id returns the existing + * checklist (re-authorized against its current card) instead of a + * duplicate. Same charset/length lockdown as the card route. + */ + id: z + .string() + .min(20) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/) + .optional(), enc_title: z.string().min(8).max(8192), /** * Optional idempotency token for the automation `append_checklist` @@ -50,6 +64,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) { const c = await Checklists.create({ cardId: id, userId, + ...(body.id ? { id: body.id } : {}), enc_title: body.enc_title, }); return NextResponse.json(c, { status: 201 }); diff --git a/src/app/api/checklists/[id]/items/route.ts b/src/app/api/checklists/[id]/items/route.ts index b1ed1e9..ceb4a4d 100644 --- a/src/app/api/checklists/[id]/items/route.ts +++ b/src/app/api/checklists/[id]/items/route.ts @@ -12,6 +12,18 @@ interface RouteContext { } const createSchema = z.object({ + /** + * Optional client-generated item id for offline create (ADR 0023) — see the + * checklist route for the rationale. A replay of the same id returns the + * existing item (re-authorized against its current board) rather than + * appending a duplicate. + */ + id: z + .string() + .min(20) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/) + .optional(), enc_content: z.string().min(8).max(8192), }); @@ -24,6 +36,7 @@ export async function POST(req: NextRequest, ctx: RouteContext) { const item = await Checklists.addItem({ checklistId: id, userId, + ...(body.id ? { id: body.id } : {}), enc_content: body.enc_content, }); return NextResponse.json(item, { status: 201 }); diff --git a/src/lib/export/account-export.ts b/src/lib/export/account-export.ts new file mode 100644 index 0000000..c97ade2 --- /dev/null +++ b/src/lib/export/account-export.ts @@ -0,0 +1,440 @@ +"use client"; + +import { + decryptFromEnvelope, + deriveBoardKey, + fromBase64Url, + unsealWorkspaceKey, + unwrapBoardKey, + type BoardKey, + type UserKeyPair, + type WorkspaceKey, +} from "@/lib/crypto"; +import { downloadBlob, type ExportedBoardV1 } from "@/lib/export/board-export"; + +/** + * Account-wide bulk export (DSGVO Art. 20 portability, account level). + * + * The per-board export covers one board at a time and needs an open board to + * do it. This walks every workspace and every board the user belongs to and + * produces one decrypted file. + * + * Why it is built client-side: the server cannot do this. It holds only + * ciphertext and no keys, so `GET /api/me/export` returns the whole nested + * tree still encrypted. Decryption happens here, walking the key hierarchy + * the user already holds: + * + * userKeyPair --(sealed box)--> WorkspaceKey --(AEAD unwrap)--> BoardKey + * + * One request, then everything is decrypted locally. That is deliberate: the + * per-board exporter fetches comments/checklists/custom-field values per card, + * which across a whole account would be thousands of requests. + * + * Note the asymmetry that is easy to get wrong: a board's *name* is encrypted + * under the WorkspaceKey (boards live in the workspace namespace), while + * everything inside the board is under the BoardKey. + */ + +/** Envelope AAD tags are bound at creation time and never rebound, so every + * decrypt tries the `:new` tag first and falls back to the `:` form. + * Returning a placeholder rather than throwing keeps one unreadable row from + * failing the entire export. */ +function tryDecrypt(opts: { + key: BoardKey | WorkspaceKey; + envelope: string; + tags: string[]; +}): T | null { + for (const bindTo of opts.tags) { + try { + return decryptFromEnvelope({ key: opts.key, envelope: opts.envelope, bindTo }); + } catch { + // try the next binding + } + } + return null; +} + +const UNREADABLE = "(unable to decrypt)"; + +// --------------------------------------------------------------------------- +// Shape of `GET /api/me/export` (ciphertext in, nothing decrypted). +// --------------------------------------------------------------------------- + +interface RawItem { + id: string; + enc_content: string; + done: boolean; + position: number; +} +interface RawChecklist { + id: string; + enc_title: string; + position: number; + items: RawItem[]; +} +interface RawComment { + id: string; + authorId: string; + enc_content: string; + createdAt: string; +} +interface RawCard { + id: string; + columnId: string; + enc_content: string; + position: number; + parentId: string | null; + dueAt: string | null; + startAt: string | null; + archived: boolean; + milestoneId: string | null; + createdAt: string; + labels: { labelId: string }[]; + assignments: { userId: string }[]; + comments: RawComment[]; + checklists: RawChecklist[]; + customValues: { fieldId: string; enc_value: string }[]; +} +interface RawColumn { + id: string; + enc_name: string; + position: number; + isDone: boolean; + wipLimit: number | null; + cards: RawCard[]; +} +interface RawBoard { + id: string; + enc_name: string; + enc_metadata: string | null; + enc_boardKey: string | null; + position: number; + archived: boolean; + labels: { id: string; color: string; enc_name: string }[]; + customFields: { id: string; enc_definition: string; position: number }[]; + cardTemplates: { id: string; enc_template: string; position: number }[]; + columns: RawColumn[]; +} +interface RawMembership { + role: string; + encWorkspaceKey: string; + workspace: { + id: string; + enc_name: string; + members: { userId: string; role: string; user: { email: string } }[]; + boards: RawBoard[]; + }; +} +export interface RawAccountExport { + workspaces: RawMembership[]; +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/** One board, in the same shape the per-board export emits, plus the ids + * needed to tell boards apart once they all sit in one file. */ +export type ExportedAccountBoard = ExportedBoardV1 & { + boardId: string; + workspaceId: string; + archived: boolean; +}; + +export interface ExportedAccountV1 { + version: 1; + generator: "mskanban"; + exportedAt: string; + workspaces: Array<{ + id: string; + name: string; + role: string; + boards: ExportedAccountBoard[]; + }>; + /** Boards that could not be decrypted at all, so a partial export never + * looks complete. */ + skipped: Array<{ workspaceId: string; boardId: string; reason: string }>; +} + +interface CardMeta { + title: string; + description?: string; +} +interface CustomFieldDef { + name: string; + type: string; + options?: string[]; +} +interface TemplateBody { + title: string; + description?: string; + checklists?: Array<{ title: string; items?: Array<{ content: string; done: boolean }> }>; +} + +function buildBoard(opts: { + raw: RawBoard; + workspaceId: string; + boardKey: BoardKey; + workspaceKey: WorkspaceKey; + members: ExportedBoardV1["members"]; + exportedAt: string; +}): ExportedAccountBoard { + const { raw, boardKey, workspaceKey } = opts; + + // Board name/description live under the WORKSPACE key, not the BoardKey. + const meta = tryDecrypt<{ name: string; description?: string }>({ + key: workspaceKey, + envelope: raw.enc_name, + tags: ["board:new", `board:${raw.id}`], + }); + + const columns = raw.columns + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + id: c.id, + name: + tryDecrypt<{ name: string }>({ + key: boardKey, + envelope: c.enc_name, + tags: ["column:new", `column:${c.id}`], + })?.name ?? UNREADABLE, + position: c.position, + wipLimit: c.wipLimit, + isDone: c.isDone, + })); + + const labels = raw.labels.map((l) => ({ + id: l.id, + name: + tryDecrypt<{ name: string }>({ + key: boardKey, + envelope: l.enc_name, + tags: ["label:new", `label:${l.id}`], + })?.name ?? "?", + color: l.color, + })); + + const customFields = raw.customFields + .slice() + .sort((a, b) => a.position - b.position) + .map((f) => { + const def = tryDecrypt({ + key: boardKey, + envelope: f.enc_definition, + tags: ["custom-field:new", `custom-field:${f.id}`], + }); + return { + id: f.id, + name: def?.name ?? UNREADABLE, + type: def?.type ?? "text", + ...(def?.options ? { options: def.options } : {}), + position: f.position, + }; + }); + + const templates = raw.cardTemplates + .slice() + .sort((a, b) => a.position - b.position) + .map((t) => + tryDecrypt({ + key: boardKey, + envelope: t.enc_template, + tags: ["card-template:new", `card-template:${t.id}`], + }), + ) + .filter((t): t is TemplateBody => t !== null) + .map((t) => ({ + title: t.title, + ...(t.description ? { description: t.description } : {}), + ...(t.checklists ? { checklists: t.checklists } : {}), + })); + + const cards: ExportedBoardV1["cards"] = []; + for (const col of raw.columns) { + for (const card of col.cards.slice().sort((a, b) => a.position - b.position)) { + const cardMeta = tryDecrypt({ + key: boardKey, + envelope: card.enc_content, + tags: ["card:new", `card:${card.id}`], + }); + cards.push({ + id: card.id, + columnId: card.columnId, + parentId: card.parentId, + title: cardMeta?.title ?? UNREADABLE, + ...(cardMeta?.description ? { description: cardMeta.description } : {}), + startAt: card.startAt, + dueAt: card.dueAt, + archived: card.archived, + position: card.position, + labelIds: card.labels.map((l) => l.labelId), + assigneeUserIds: card.assignments.map((a) => a.userId), + comments: card.comments.map((cm) => ({ + authorId: cm.authorId, + body: + tryDecrypt<{ body: string }>({ + key: boardKey, + envelope: cm.enc_content, + tags: ["comment:new", `comment:${cm.id}`], + })?.body ?? UNREADABLE, + createdAt: cm.createdAt, + })), + checklists: card.checklists + .slice() + .sort((a, b) => a.position - b.position) + .map((cl) => ({ + title: + tryDecrypt<{ title: string }>({ + key: boardKey, + envelope: cl.enc_title, + tags: ["checklist:new", `checklist:${cl.id}`], + })?.title ?? UNREADABLE, + items: cl.items + .slice() + .sort((a, b) => a.position - b.position) + .map((it) => ({ + content: + tryDecrypt<{ content: string }>({ + key: boardKey, + envelope: it.enc_content, + tags: ["checklist-item:new", `checklist-item:${it.id}`], + })?.content ?? UNREADABLE, + done: it.done, + })), + })), + customFieldValues: card.customValues.map((v) => ({ + fieldId: v.fieldId, + value: + tryDecrypt<{ value: unknown }>({ + key: boardKey, + envelope: v.enc_value, + tags: ["custom-field-value:new"], + })?.value ?? null, + })), + }); + } + } + + return { + version: 1, + generator: "mskanban", + exportedAt: opts.exportedAt, + board: { + name: meta?.name ?? UNREADABLE, + ...(meta?.description ? { description: meta.description } : {}), + }, + columns, + labels, + members: opts.members, + customFields, + cards, + templates, + boardId: raw.id, + workspaceId: opts.workspaceId, + archived: raw.archived, + }; +} + +/** + * Decrypt a raw `/api/me/export` payload into every board it contains. + * Pure apart from the crypto calls, so it is unit-testable without a server. + * `onProgress` reports boards finished / total for the UI. + */ +export async function buildAccountExport(opts: { + raw: RawAccountExport; + userKeyPair: UserKeyPair; + exportedAt: string; + onProgress?: (done: number, total: number) => void; +}): Promise { + const { raw, userKeyPair } = opts; + const total = raw.workspaces.reduce((n, m) => n + m.workspace.boards.length, 0); + let done = 0; + + const workspaces: ExportedAccountV1["workspaces"] = []; + const skipped: ExportedAccountV1["skipped"] = []; + + for (const membership of raw.workspaces) { + const ws = membership.workspace; + let wsKey: WorkspaceKey; + try { + wsKey = await unsealWorkspaceKey(fromBase64Url(membership.encWorkspaceKey), userKeyPair); + } catch { + // Without the workspace key nothing below it is readable. Record every + // board so the file states what is missing instead of omitting it. + for (const b of ws.boards) { + skipped.push({ workspaceId: ws.id, boardId: b.id, reason: "workspace key unavailable" }); + done += 1; + } + opts.onProgress?.(done, total); + continue; + } + + const members = ws.members.map((m) => ({ + userId: m.userId, + email: m.user.email, + role: m.role, + })); + + const boards: ExportedAccountBoard[] = []; + for (const rawBoard of ws.boards) { + let boardKey: BoardKey; + try { + // A null wrap means a legacy board whose key is derived, not wrapped + // (ADR 0015). + boardKey = rawBoard.enc_boardKey + ? await unwrapBoardKey(rawBoard.enc_boardKey, wsKey) + : await deriveBoardKey(wsKey, rawBoard.id); + } catch { + skipped.push({ + workspaceId: ws.id, + boardId: rawBoard.id, + reason: "board key could not be unwrapped", + }); + done += 1; + opts.onProgress?.(done, total); + continue; + } + boards.push( + buildBoard({ + raw: rawBoard, + workspaceId: ws.id, + boardKey, + workspaceKey: wsKey, + members, + exportedAt: opts.exportedAt, + }), + ); + done += 1; + opts.onProgress?.(done, total); + } + + workspaces.push({ + id: ws.id, + name: + tryDecrypt<{ name: string }>({ + key: wsKey, + envelope: ws.enc_name, + tags: ["workspace:new", `workspace:${ws.id}`], + })?.name ?? UNREADABLE, + role: membership.role, + boards, + }); + } + + return { + version: 1, + generator: "mskanban", + exportedAt: opts.exportedAt, + workspaces, + skipped, + }; +} + +export function downloadAccountExport(snapshot: ExportedAccountV1): void { + downloadBlob({ + content: JSON.stringify(snapshot, null, 2), + mime: "application/json", + filename: "mskanban-all-boards.json", + }); +} diff --git a/src/lib/export/board-export.ts b/src/lib/export/board-export.ts index 91cfddc..a3ac64e 100644 --- a/src/lib/export/board-export.ts +++ b/src/lib/export/board-export.ts @@ -669,7 +669,8 @@ function safeFilename(name: string): string { return name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "board"; } -function downloadBlob(opts: { content: string; mime: string; filename: string }): void { +/** Shared by the account-wide bulk export (`account-export.ts`). */ +export function downloadBlob(opts: { content: string; mime: string; filename: string }): void { const blob = new Blob([opts.content], { type: opts.mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/src/lib/repositories/account.ts b/src/lib/repositories/account.ts index 405e689..6140175 100644 --- a/src/lib/repositories/account.ts +++ b/src/lib/repositories/account.ts @@ -54,6 +54,14 @@ export async function buildAccountExport(userId: string) { enc_name: true, enc_metadata: true, createdAt: true, + // Co-members, so an exported board can be re-imported with its + // assignees intact (the importer matches them by email). This is not + // new exposure: GET /api/workspaces/[wsId]/members already returns + // {userId, email, role} for every member to any member. Kept to + // those three fields for that reason. + members: { + select: { userId: true, role: true, user: { select: { email: true } } }, + }, boards: { select: { id: true, @@ -64,6 +72,10 @@ export async function buildAccountExport(userId: string) { archived: true, labels: { select: { id: true, color: true, enc_name: true } }, customFields: { select: { id: true, enc_definition: true, position: true } }, + // Card templates are board content the user authored, so an + // Art. 15/20 export has to carry them; the board export format + // has a `templates` section that would otherwise always be empty. + cardTemplates: { select: { id: true, enc_template: true, position: true } }, milestones: { select: { id: true, enc_name: true, startAt: true, endAt: true, archived: true }, }, @@ -80,6 +92,10 @@ export async function buildAccountExport(userId: string) { columnId: true, enc_content: true, position: true, + // Mind-map parent link. Server-visible metadata, and the + // board export format round-trips it, so leaving it out + // silently flattened the hierarchy on re-import. + parentId: true, dueAt: true, startAt: true, archived: true, diff --git a/src/lib/repositories/checklists.ts b/src/lib/repositories/checklists.ts index b34e1a7..2c7a046 100644 --- a/src/lib/repositories/checklists.ts +++ b/src/lib/repositories/checklists.ts @@ -91,8 +91,40 @@ export async function listForCard(opts: { export async function create(opts: { cardId: string; userId: string; + /** Optional client-generated id for offline create (ADR 0023). */ + id?: string; enc_title: string; }): Promise { + // Idempotent replay (ADR 0023): a client-minted id that already exists means + // the offline outbox is replaying this op. Re-authorize against the row's + // CURRENT card, then return it unchanged instead of creating a duplicate. + // Authorizing on the existing row (not the requested cardId) is what stops a + // caller from probing or hijacking a checklist that has since moved. + if (opts.id) { + const existing = await prisma.checklist.findUnique({ + where: { id: opts.id }, + select: { id: true, cardId: true, enc_title: true, position: true, items: true }, + }); + if (existing) { + const existingBoardId = await boardIdForCard(existing.cardId); + await requireBoardWrite({ boardId: existingBoardId, userId: opts.userId }); + return { + id: existing.id, + cardId: existing.cardId, + enc_title: existing.enc_title, + position: existing.position, + items: existing.items + .sort((a, b) => a.position - b.position) + .map((i) => ({ + id: i.id, + checklistId: i.checklistId, + enc_content: i.enc_content, + done: i.done, + position: i.position, + })), + }; + } + } const boardId = await boardIdForCard(opts.cardId); await requireBoardWrite({ boardId, userId: opts.userId }); const tail = await prisma.checklist.findFirst({ @@ -102,7 +134,12 @@ export async function create(opts: { }); const position = (tail?.position ?? 0) + 1; const created = await prisma.checklist.create({ - data: { cardId: opts.cardId, enc_title: opts.enc_title, position }, + data: { + ...(opts.id ? { id: opts.id } : {}), + cardId: opts.cardId, + enc_title: opts.enc_title, + position, + }, include: { items: true }, }); return { @@ -136,8 +173,30 @@ export async function remove(opts: { checklistId: string; userId: string }): Pro export async function addItem(opts: { checklistId: string; userId: string; + /** Optional client-generated id for offline create (ADR 0023). */ + id?: string; enc_content: string; }): Promise { + // Idempotent replay (ADR 0023) — same contract as `create` above: authorize + // against the board the existing item currently lives on, then return it + // unchanged rather than adding a duplicate. + if (opts.id) { + const existing = await prisma.checklistItem.findUnique({ + where: { id: opts.id }, + select: { + id: true, + checklistId: true, + enc_content: true, + done: true, + position: true, + }, + }); + if (existing) { + const existingBoardId = await boardIdForChecklist(existing.checklistId); + await requireBoardWrite({ boardId: existingBoardId, userId: opts.userId }); + return existing; + } + } const boardId = await boardIdForChecklist(opts.checklistId); await requireBoardWrite({ boardId, userId: opts.userId }); const tail = await prisma.checklistItem.findFirst({ @@ -148,6 +207,7 @@ export async function addItem(opts: { const position = (tail?.position ?? 0) + 1; const item = await prisma.checklistItem.create({ data: { + ...(opts.id ? { id: opts.id } : {}), checklistId: opts.checklistId, enc_content: opts.enc_content, position, diff --git a/tests/e2e/export-and-offline-template.spec.ts b/tests/e2e/export-and-offline-template.spec.ts new file mode 100644 index 0000000..d5c9c53 --- /dev/null +++ b/tests/e2e/export-and-offline-template.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from "@playwright/test"; + +/** + * End-to-end coverage for the two backlog follow-ups: + * + * 1. Creating a card from a template while OFFLINE. The chain (card -> + * checklist -> item) used to need each server response to learn the next + * id, so it could not be queued. Playwright's `context.setOffline` drives + * the real `navigator.onLine` branch, so this exercises the outbox for + * real rather than a mocked store. + * + * 2. The account-wide bulk export. The unit tests prove the decrypt pipeline + * against a hand-built fixture; only an end-to-end run proves the pieces + * line up with what the server actually stores and what the key hierarchy + * actually holds. The assertion is deliberately on PLAINTEXT appearing in + * the downloaded file: that can only happen if sealed workspace key -> + * wrapped board key -> content all resolved. + * + * Registration runs two sequential Argon2id derivations under libsodium-WASM, + * which is slow on CI hardware, hence the generous timeout (same reasoning as + * kanban.spec.ts). + */ +test.setTimeout(240_000); + +test("offline template create, then account-wide decrypted export", async ({ page, context }) => { + page.on("pageerror", (err) => console.log(`[browser pageerror] ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error") console.log(`[browser error] ${msg.text()}`); + }); + + const email = `e2e-export-${Date.now()}@example.com`; + const password = "LongEnoughPassword!23"; + + // ---------------- Register ---------------- + await page.goto("/register"); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/^password \(/i).fill(password); + await page.getByLabel(/confirm password/i).fill(password); + await page.getByRole("button", { name: /create account/i }).click(); + + await expect(page.getByRole("heading", { name: /write this down/i })).toBeVisible({ + timeout: 120_000, + }); + await page.getByLabel(/i have copied or printed/i).check(); + await page.getByRole("button", { name: /continue and create account/i }).click(); + await expect(page).toHaveURL(/\/workspaces$/, { timeout: 30_000 }); + + // ---------------- Workspace + board ---------------- + await page.getByPlaceholder("Name").fill("Export WS"); + await page.getByRole("button", { name: /^create$/i }).click(); + await page.getByRole("link", { name: /export ws/i }).click(); + + await page.getByPlaceholder(/new board name/i).fill("Export Board"); + await page.getByRole("button", { name: /add board/i }).click(); + await page.getByRole("link", { name: /export board/i }).click(); + + const backlog = page.getByRole("region", { name: /backlog/i }); + await expect(backlog).toBeVisible(); + + // ---------------- A card with a checklist ---------------- + await backlog.getByRole("button", { name: /add a card/i }).click(); + await backlog.getByPlaceholder(/card title/i).fill("Seed card"); + await backlog.getByRole("button", { name: /^add$/i }).click(); + await expect(backlog.getByText("Seed card")).toBeVisible(); + + await backlog.getByRole("button", { name: /open card/i }).first().click(); + const drawer = page.getByRole("dialog", { name: /card details/i }); + await expect(drawer).toBeVisible(); + + await drawer.getByRole("button", { name: /new checklist/i }).click(); + await drawer.getByPlaceholder("Checklist title").fill("Steps"); + await drawer.getByRole("button", { name: /^create$/i }).click(); + await expect(drawer.getByText("Steps")).toBeVisible(); + + await drawer.getByPlaceholder(/add an item/i).fill("Do the thing"); + await drawer.getByRole("button", { name: /^add$/i }).click(); + await expect(drawer.getByText("Do the thing")).toBeVisible(); + + // Save it as a template, so the template carries a checklist chain. That is + // what makes the offline case non-trivial: it is a multi-step POST chain. + await drawer.getByRole("button", { name: /save as template/i }).click(); + await drawer.getByRole("button", { name: /^close$/i }).click(); + await expect(drawer).toBeHidden(); + + // ---------------- Create from template, OFFLINE ---------------- + await context.setOffline(true); + + await backlog.getByRole("button", { name: /from template/i }).click(); + await backlog.getByRole("button", { name: /create card from template seed card/i }).click(); + + // The optimistic card must appear even though nothing reached the server. + // Before this change the whole action aborted silently while offline. + await expect(backlog.getByText("Seed card")).toHaveCount(2, { timeout: 15_000 }); + + // ---------------- Reconnect and let the outbox drain ---------------- + const boardId = page.url().split("/boards/")[1]!.split(/[/?#]/)[0]!; + + await context.setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + + // Assert against the SERVER rather than reloading the page. A reload would + // drop the in-memory master key and land on the unlock gate (ADR 0009), + // which says nothing about whether the queue replayed. Querying the API with + // the same session cookie shows exactly what was persisted. + // The queries run inside the page rather than through page.request: the + // session cookie is SameSite=Strict, which Playwright's separate request + // context does not carry, so it would see 401 regardless of the data. + await expect(async () => { + const state = await page.evaluate(async (bid) => { + const r = await fetch(`/api/boards/${bid}/cards`, { credentials: "include" }); + if (!r.ok) return { status: r.status, cardCount: -1, checklistCount: -1, itemCounts: [] }; + const { cards } = (await r.json()) as { cards: { id: string }[] }; + let checklistCount = 0; + const itemCounts: number[] = []; + for (const c of cards) { + const cr = await fetch(`/api/cards/${c.id}/checklists`, { credentials: "include" }); + const body = (await cr.json()) as { checklists: { items: unknown[] }[] }; + checklistCount += body.checklists.length; + for (const cl of body.checklists) itemCounts.push(cl.items.length); + } + return { status: r.status, cardCount: cards.length, checklistCount, itemCounts }; + }, boardId); + + expect(state.status).toBe(200); + expect(state.cardCount).toBe(2); + // Both the seeded card and the templated copy carry a checklist with its + // item. The copy's only exists if the child POSTs landed against the card + // id the client minted while offline. + expect(state.checklistCount).toBe(2); + expect(state.itemCounts).toEqual([1, 1]); + }).toPass({ timeout: 60_000 }); + + // ---------------- Account-wide decrypted export ---------------- + // Client-side navigation on purpose: a full load would discard the in-memory + // key bundle and the export section would (correctly) report a locked vault. + await page.getByRole("link", { name: email }).click(); + await expect(page.getByRole("heading", { name: /account/i }).first()).toBeVisible({ + timeout: 20_000, + }); + page.once("dialog", (d) => void d.accept()); + + const downloadPromise = page.waitForEvent("download", { timeout: 90_000 }); + await page.getByRole("button", { name: /download all boards/i }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("mskanban-all-boards.json"); + + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(c as Buffer); + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + + // Plaintext in the file is the whole point: it can only be there if the + // client walked sealed workspace key -> wrapped board key -> content. + expect(parsed.version).toBe(1); + expect(parsed.skipped).toEqual([]); + const ws = parsed.workspaces.find((w: { name: string }) => w.name === "Export WS"); + expect(ws, "workspace name decrypted").toBeTruthy(); + + const board = ws.boards.find((b: { board: { name: string } }) => b.board.name === "Export Board"); + expect(board, "board name decrypted under the workspace key").toBeTruthy(); + expect(board.columns.map((c: { name: string }) => c.name)).toContain("Backlog"); + + const titles = board.cards.map((c: { title: string }) => c.title); + expect(titles.filter((t: string) => t === "Seed card").length).toBe(2); + + // Nested content decrypts too, and the template section is populated (it was + // always empty before, because the server dump never selected templates). + const withChecklist = board.cards.find( + (c: { checklists: unknown[] }) => c.checklists.length > 0, + ); + expect(withChecklist.checklists[0].title).toBe("Steps"); + expect(withChecklist.checklists[0].items[0].content).toBe("Do the thing"); + expect(board.templates.length).toBeGreaterThan(0); + expect(board.members[0].email).toBe(email); +}); diff --git a/tests/unit/account-export.test.ts b/tests/unit/account-export.test.ts new file mode 100644 index 0000000..427ae54 --- /dev/null +++ b/tests/unit/account-export.test.ts @@ -0,0 +1,268 @@ +// @vitest-environment node +import { beforeAll, describe, expect, it } from "vitest"; +import { + ready, + generateWorkspaceKey, + generateBoardKey, + generateUserKeyPair, + wrapBoardKey, + sealWorkspaceKeyFor, + encryptToEnvelope, + toBase64Url, +} from "@/lib/crypto"; +import { buildAccountExport, type RawAccountExport } from "@/lib/export/account-export"; + +/** + * Account-wide bulk export (DSGVO Art. 20). + * + * These tests run the REAL crypto rather than stubbing it, because the whole + * point of the feature is that the client can walk the key hierarchy on its + * own: sealed WorkspaceKey -> wrapped BoardKey -> content. Stubbing that away + * would leave the interesting part unproven. + * + * The fixture is built the same way the app builds it, so a mistake in the + * AAD bindings or in which key a field belongs to shows up as an undecryptable + * value instead of passing silently. + */ + +const EXPORTED_AT = "2026-07-25T00:00:00.000Z"; + +async function fixture() { + const user = await generateUserKeyPair(); + const wsKey = await generateWorkspaceKey(); + const boardKey = await generateBoardKey(); + const boardId = "board_1"; + + const raw: RawAccountExport = { + workspaces: [ + { + role: "OWNER", + encWorkspaceKey: toBase64Url(await sealWorkspaceKeyFor(wsKey, user.publicKey)), + workspace: { + id: "ws_1", + // Workspace + board names are sealed under the WORKSPACE key. + enc_name: encryptToEnvelope({ + key: wsKey, + payload: { name: "Acme" }, + bindTo: "workspace:new", + }).serialised, + members: [ + { userId: "u1", role: "OWNER", user: { email: "owner@example.com" } }, + { userId: "u2", role: "MEMBER", user: { email: "member@example.com" } }, + ], + boards: [ + { + id: boardId, + enc_name: encryptToEnvelope({ + key: wsKey, + payload: { name: "Roadmap" }, + bindTo: "board:new", + }).serialised, + enc_metadata: null, + enc_boardKey: await wrapBoardKey(boardKey, wsKey), + position: 0, + archived: false, + labels: [ + { + id: "lab_1", + color: "#ff0000", + enc_name: encryptToEnvelope({ + key: boardKey, + payload: { name: "urgent" }, + bindTo: "label:new", + }).serialised, + }, + ], + customFields: [], + cardTemplates: [ + { + id: "tpl_1", + position: 0, + enc_template: encryptToEnvelope({ + key: boardKey, + payload: { title: "Bug report" }, + bindTo: "card-template:new", + }).serialised, + }, + ], + columns: [ + { + id: "col_1", + position: 0, + isDone: false, + wipLimit: null, + enc_name: encryptToEnvelope({ + key: boardKey, + payload: { name: "Backlog" }, + bindTo: "column:new", + }).serialised, + cards: [ + { + id: "card_1", + columnId: "col_1", + position: 1000, + parentId: null, + dueAt: null, + startAt: null, + archived: false, + milestoneId: null, + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { title: "Parent card" }, + bindTo: "card:new", + }).serialised, + labels: [{ labelId: "lab_1" }], + assignments: [{ userId: "u2" }], + comments: [ + { + id: "cm_1", + authorId: "u2", + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { body: "looks good" }, + bindTo: "comment:new", + }).serialised, + }, + ], + checklists: [ + { + id: "cl_1", + position: 0, + enc_title: encryptToEnvelope({ + key: boardKey, + payload: { title: "Steps" }, + bindTo: "checklist:new", + }).serialised, + items: [ + { + id: "it_1", + position: 0, + done: true, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { content: "reproduce" }, + bindTo: "checklist-item:new", + }).serialised, + }, + ], + }, + ], + customValues: [], + }, + { + id: "card_2", + columnId: "col_1", + position: 2000, + // Mind-map link, which used to be dropped from the + // account export entirely. + parentId: "card_1", + dueAt: null, + startAt: null, + archived: false, + milestoneId: null, + createdAt: EXPORTED_AT, + enc_content: encryptToEnvelope({ + key: boardKey, + payload: { title: "Child card" }, + bindTo: "card:new", + }).serialised, + labels: [], + assignments: [], + comments: [], + checklists: [], + customValues: [], + }, + ], + }, + ], + }, + ], + }, + }, + ], + }; + return { raw, user, wsKey, boardKey }; +} + +describe("account-wide bulk export", () => { + beforeAll(async () => { + await ready(); + }); + + it("decrypts every level of the hierarchy from the user key alone", async () => { + const { raw, user } = await fixture(); + + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + + expect(out.skipped).toEqual([]); + expect(out.workspaces).toHaveLength(1); + const ws = out.workspaces[0]!; + expect(ws.name).toBe("Acme"); + expect(ws.role).toBe("OWNER"); + + const board = ws.boards[0]!; + expect(board.board.name).toBe("Roadmap"); + expect(board.boardId).toBe("board_1"); + expect(board.workspaceId).toBe("ws_1"); + expect(board.columns[0]!.name).toBe("Backlog"); + expect(board.labels[0]!.name).toBe("urgent"); + expect(board.templates[0]!.title).toBe("Bug report"); + + const parent = board.cards.find((c) => c.id === "card_1")!; + expect(parent.title).toBe("Parent card"); + expect(parent.comments[0]!.body).toBe("looks good"); + expect(parent.checklists[0]!.title).toBe("Steps"); + expect(parent.checklists[0]!.items[0]).toEqual({ content: "reproduce", done: true }); + expect(parent.labelIds).toEqual(["lab_1"]); + expect(parent.assigneeUserIds).toEqual(["u2"]); + }); + + it("carries members and mind-map parent links so a re-import can rebuild them", async () => { + const { raw, user } = await fixture(); + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + const board = out.workspaces[0]!.boards[0]!; + + // Assignees are matched by email on import, so the member list has to ship. + expect(board.members).toEqual([ + { userId: "u1", email: "owner@example.com", role: "OWNER" }, + { userId: "u2", email: "member@example.com", role: "MEMBER" }, + ]); + expect(board.cards.find((c) => c.id === "card_2")!.parentId).toBe("card_1"); + }); + + it("records a board it cannot decrypt instead of silently omitting it", async () => { + const { raw, user } = await fixture(); + // Simulate a board wrapped under a key this user does not hold. + const foreignWsKey = await generateWorkspaceKey(); + const foreignBoardKey = await generateBoardKey(); + raw.workspaces[0]!.workspace.boards[0]!.enc_boardKey = await wrapBoardKey( + foreignBoardKey, + foreignWsKey, + ); + + const out = await buildAccountExport({ raw, userKeyPair: user, exportedAt: EXPORTED_AT }); + + expect(out.workspaces[0]!.boards).toHaveLength(0); + expect(out.skipped).toEqual([ + { + workspaceId: "ws_1", + boardId: "board_1", + reason: "board key could not be unwrapped", + }, + ]); + }); + + it("reports progress per board", async () => { + const { raw, user } = await fixture(); + const seen: Array<[number, number]> = []; + await buildAccountExport({ + raw, + userKeyPair: user, + exportedAt: EXPORTED_AT, + onProgress: (done, total) => seen.push([done, total]), + }); + expect(seen).toEqual([[1, 1]]); + }); +}); diff --git a/tests/unit/offline-template.test.ts b/tests/unit/offline-template.test.ts new file mode 100644 index 0000000..bb43398 --- /dev/null +++ b/tests/unit/offline-template.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +/** + * Offline "create card from template" (ADR 0023 follow-up). + * + * Creating a card from a template is a POST chain: card, then a checklist per + * template checklist, then an item per checklist item, plus a PATCH for items + * the template ticks. It used to be online-only because every step needed the + * id the *previous* response returned, so nothing could be queued. + * + * Minting every id on the client up front removes that dependency. These tests + * pin the two properties the chain relies on: + * 1. offline, the whole chain queues, and replays in parent-before-child + * order with the client ids intact (a child must never reach the server + * before the row it hangs off), + * 2. a replay that hits an already-created row is idempotent, so a partially + * applied chain can safely be replayed in full. + * + * The IDB outbox is mocked with an in-memory store; `navigator.onLine` drives + * the offline branch in `queuedFetch`. + */ + +const store = new Map>(); + +vi.mock("@/lib/idb/outbox", () => ({ + addEntry: vi.fn(async (e: { id: string; userId: string }) => { + store.set(e.id, e); + }), + allEntries: vi.fn(async (u: string) => + [...store.values()] + .filter((e) => e.userId === u) + .sort( + (a, b) => + (a.createdAt as number) - (b.createdAt as number) || + (a.seq as number) - (b.seq as number), + ), + ), + deleteEntry: vi.fn(async (id: string) => { + store.delete(id); + }), + countEntries: vi.fn( + async (u: string) => [...store.values()].filter((e) => e.userId === u).length, + ), +})); + +import { queuedFetch, flushOutbox } from "@/lib/offline/sync"; + +beforeEach(() => { + store.clear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const CARD_ID = "tpl-card-uuid-000000001"; +const CL_ID = "tpl-checklist-uuid-00001"; +const ITEM_ID = "tpl-item-uuid-000000001"; + +/** + * The op sequence board-client emits for a template with one checklist that + * holds one ticked item. Ids are minted before any request goes out. + */ +const CHAIN = [ + { + boardId: "b1", + label: 'Create card from template "Bug report"', + method: "POST" as const, + url: "/api/columns/col1/cards", + body: { id: CARD_ID, enc_content: "v1.nonce.card" }, + }, + { + boardId: "b1", + label: 'Add checklist "Steps"', + method: "POST" as const, + url: `/api/cards/${CARD_ID}/checklists`, + body: { id: CL_ID, enc_title: "v1.nonce.cl" }, + }, + { + boardId: "b1", + label: "Add checklist item", + method: "POST" as const, + url: `/api/checklists/${CL_ID}/items`, + body: { id: ITEM_ID, enc_content: "v1.nonce.item" }, + }, + { + boardId: "b1", + label: "Tick checklist item", + method: "PATCH" as const, + url: `/api/checklist-items/${ITEM_ID}`, + body: { done: true }, + }, +]; + +describe("offline create-from-template", () => { + it("queues the whole chain offline instead of fetching", async () => { + vi.stubGlobal("navigator", { onLine: false }); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + for (const op of CHAIN) { + const { queued, res } = await queuedFetch("u1", op); + expect(queued).toBe(true); + expect(res).toBeNull(); + } + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(store.size).toBe(4); + }); + + it("replays parent before child, with the client ids intact", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + const calls: { url: string; method: string; body: unknown }[] = []; + const fakeFetch = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ + url, + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(init.body as string) : undefined, + }); + return { status: 201 } as Response; + }); + + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(4); + expect(summary.remaining).toBe(0); + + // Order is the invariant that makes the chain safe: the card must exist + // before its checklist, the checklist before its item, the item before the + // PATCH that ticks it. + expect(calls.map((c) => c.url)).toEqual([ + "/api/columns/col1/cards", + `/api/cards/${CARD_ID}/checklists`, + `/api/checklists/${CL_ID}/items`, + `/api/checklist-items/${ITEM_ID}`, + ]); + + // The ids the client minted are the ones that reach the server, which is + // what lets the later URLs reference rows that do not exist yet locally. + expect((calls[0]!.body as { id: string }).id).toBe(CARD_ID); + expect((calls[1]!.body as { id: string }).id).toBe(CL_ID); + expect((calls[2]!.body as { id: string }).id).toBe(ITEM_ID); + expect(calls[3]!.body).toEqual({ done: true }); + }); + + it("treats an idempotent replay (200 on an existing row) as synced", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + // A chain that was already applied server-side: every create returns the + // existing row with 200 rather than 201. Nothing may be re-queued or + // reported as a conflict. + const fakeFetch = vi.fn(async () => ({ status: 200 }) as Response); + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(4); + expect(summary.dropped).toBe(0); + expect(summary.droppedLabels).toEqual([]); + expect(store.size).toBe(0); + }); + + it("stops mid-chain on a transient failure and keeps the tail queued", async () => { + vi.stubGlobal("navigator", { onLine: false }); + for (const op of CHAIN) await queuedFetch("u1", op); + + // The card lands, then the server hiccups. The remaining ops must stay + // queued in order rather than being dropped, otherwise the card would be + // left without the checklist the template promised. + let n = 0; + const fakeFetch = vi.fn(async () => { + n += 1; + return { status: n === 1 ? 201 : 503 } as Response; + }); + + const summary = await flushOutbox("u1", { + fetchFn: fakeFetch as unknown as typeof fetch, + }); + + expect(summary.synced).toBe(1); + expect(summary.dropped).toBe(0); + expect(summary.remaining).toBe(3); + // The flush stops at the first transient failure instead of racing ahead. + expect(fakeFetch).toHaveBeenCalledTimes(2); + }); +});