From 813a7ba697fe018d7591d114179454a7e20611c4 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:46:50 -0400 Subject: [PATCH 1/5] test(03-10): add RED tests for ICS builder wiring + WR-04 + CR-02 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases (all-day DTEND+1 fails: emits 20260610 not 20260611) - outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR to CalDAV write functions (fails: raw JSON passes through today) - worker: unparseable payload must mark row failed (fails: marks done today) - Update makeRow default payload to form JSON shape the worker should parse --- apps/api/tests/broker/outboxWorker.test.ts | 66 +++++++++++++++++++++- apps/api/tests/broker/vevent.test.ts | 43 ++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 2ca3f62..bef65ae 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -73,6 +73,15 @@ vi.mock('../../src/broker/client.js', () => ({ }), })) +// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV) +const DEFAULT_FORM_PAYLOAD = JSON.stringify({ + title: 'Lunch', + allDay: false, + start: '2026-06-10T12:00:00', + end: '2026-06-10T13:00:00', + recurrence: 'none', +}) + const makeRow = (overrides: Record = {}) => ({ id: 1, userId: 42, @@ -82,7 +91,7 @@ const makeRow = (overrides: Record = {}) => ({ calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/', calendarObjectUrl: null, etag: null, - payload: 'BEGIN:VCALENDAR\r\nEND:VCALENDAR', + payload: DEFAULT_FORM_PAYLOAD, attemptCount: 0, nextAttemptAt: new Date(Date.now() - 5000), // already due lastError: null, @@ -175,6 +184,61 @@ describe('runOutboxDrain — state transitions', () => { }) }) +describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockUpdate.mockReturnValue({ set: mockUpdateSet }) + mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) + mockFromFn.mockReturnValue({ where: mockWherePending }) + mockSelectFn.mockReturnValue({ from: mockFromFn }) + }) + + it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + let capturedIcsString: unknown = null + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString + return makeResponse(201) + }) + mockPendingRows = [makeRow()] + + await runOutboxDrain() + + expect(typeof capturedIcsString).toBe('string') + expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true) + expect(capturedIcsString).toContain('SUMMARY:Lunch') + }) + + it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => { + const { updateCalendarEvent } = await import('../../src/broker/write.js') + let capturedIcsString: unknown = null + vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => { + capturedIcsString = icsString + return makeResponse(204) + }) + mockPendingRows = [makeRow({ + operation: 'update', + calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics', + })] + + await runOutboxDrain() + + expect(typeof capturedIcsString).toBe('string') + expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true) + }) + + it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => { + mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })] + + await runOutboxDrain() + + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string } + expect(setArg?.status).toBe('failed') + }) +}) + describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/api/tests/broker/vevent.test.ts b/apps/api/tests/broker/vevent.test.ts index 81b85b9..52d608b 100644 --- a/apps/api/tests/broker/vevent.test.ts +++ b/apps/api/tests/broker/vevent.test.ts @@ -116,3 +116,46 @@ describe('buildVeventString', () => { expect(result.uid.length).toBeGreaterThan(10) }) }) + +describe('buildVeventString — D-13 form-parsed contract', () => { + it('timed event: produces BEGIN:VCALENDAR, SUMMARY, UID, timed DTSTART (Z suffix), and DTEND', () => { + // Simulates the field shape parsed by the worker from the stored form JSON + const result = buildVeventString({ + uid: 'u1@familysync', + summary: 'Lunch', + allDay: false, + dtstart: new Date('2026-06-10T12:00:00Z'), + dtend: new Date('2026-06-10T13:00:00Z'), + }) + + expect(result.icsString).toContain('BEGIN:VCALENDAR') + expect(result.icsString).toContain('SUMMARY:Lunch') + expect(result.icsString).toContain('UID:u1@familysync') + // D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID + expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/) + // DTEND must be present + expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/) + }) + + it('single-day all-day event: DTSTART is DATE format and DTEND = DTSTART + 1 day (RFC-5545 exclusive end, WR-04)', () => { + // Simulates a single-day all-day event where start and end are the same calendar day. + // WR-04: the outbox worker passes the user-entered inclusive end; vevent.ts must advance it. + const result = buildVeventString({ + summary: 'Birthday', + allDay: true, + dtstart: '2026-06-10', + dtend: '2026-06-10', + }) + + // DTSTART must be DATE format (no time component, no TZID) — D-13 all-day contract + expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/) + expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/) + + // WR-04: DTEND must be DTSTART + 1 day (RFC-5545 exclusive end) + expect(result.icsString).toMatch(/DTEND[^:]*:20260611/) + // DTEND date string must NOT equal DTSTART date string (owning-boundary assertion) + const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/) + const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/) + expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]) + }) +}) From c03b47938ed62b561c6dc66c4e11ccb3dd468229 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:47:50 -0400 Subject: [PATCH 2/5] feat(03-10): wire buildVeventString into dispatch path + fix all-day DTEND+1 (CR-02, WR-04) - outboxWorker: parse stored form JSON, build VCALENDAR via buildVeventString for create/update - outboxWorker: return hardFail on payload parse error (corrupt payload never self-resolves) - outboxWorker: import buildVeventString and RRULE_PRESETS from vevent.js - vevent.ts: advance all-day DTEND by +1 calendar day (RFC-5545 exclusive end, WR-04 owning boundary) --- apps/api/src/broker/outboxWorker.ts | 39 +++++++++++++++++++++++++++-- apps/api/src/broker/vevent.ts | 11 +++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 016104b..d0eaf82 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -31,6 +31,7 @@ import { createFastmailClient } from './client.js' import { decryptPassword } from './crypto.js' import { syncCalendar } from './sync.js' import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from './write.js' +import { buildVeventString, RRULE_PRESETS } from './vevent.js' import type { FastmailClient } from './client.js' // ── Constants (D-07) ──────────────────────────────────────────────────────── @@ -165,10 +166,27 @@ async function dispatchRow(row: OutboxRow): Promise { error: 'update operation missing payload or calendarObjectUrl', } } + // CR-02: parse the stored form JSON and build a real VCALENDAR string + let fields: Record + try { + fields = JSON.parse(row.payload) as Record + } catch { + return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' } + } + const { icsString } = buildVeventString({ + uid: row.uid, + summary: fields.title as string, + allDay: fields.allDay as boolean, + dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string), + dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), + location: fields.location as string | undefined, + description: fields.description as string | undefined, + rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] : undefined, + }) response = await updateCalendarEvent( client, row.calendarObjectUrl, - row.payload, + icsString, row.etag ?? null, ) } else { @@ -182,9 +200,26 @@ async function dispatchRow(row: OutboxRow): Promise { error: 'create operation missing payload', } } + // CR-02: parse the stored form JSON and build a real VCALENDAR string + let fields: Record + try { + fields = JSON.parse(row.payload) as Record + } catch { + return { success: false, conflict: false, hardFail: true, transient: false, error: 'payload parse failed' } + } + const { icsString } = buildVeventString({ + uid: row.uid, + summary: fields.title as string, + allDay: fields.allDay as boolean, + dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string), + dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string), + location: fields.location as string | undefined, + description: fields.description as string | undefined, + rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence as string] : undefined, + }) // Build a minimal DAVCalendar for the write wrapper (only url is needed) const davCalendar = { url: row.calendarUrl } as Parameters[1] - response = await createCalendarEvent(client, davCalendar, row.uid, row.payload) + response = await createCalendarEvent(client, davCalendar, row.uid, icsString) } const status = response.status diff --git a/apps/api/src/broker/vevent.ts b/apps/api/src/broker/vevent.ts index 7089e8f..de023c6 100644 --- a/apps/api/src/broker/vevent.ts +++ b/apps/api/src/broker/vevent.ts @@ -80,10 +80,19 @@ export function buildVeventString(params: NewEventParams): { uid: string; icsStr const [sy, sm, sd] = startStr.split('-').map(Number) as [number, number, number] const [ey, em, ed] = endStr.split('-').map(Number) as [number, number, number] + // WR-04 (owning boundary): RFC-5545 §3.6.1 — DTEND for an all-day event is the + // EXCLUSIVE end date. Advance the user-entered inclusive end by one calendar day. + // Building a Date from UTC components ensures no DST ambiguity during the roll-over. + const endDate = new Date(Date.UTC(ey, em - 1, ed)) + endDate.setUTCDate(endDate.getUTCDate() + 1) + const ey2 = endDate.getUTCFullYear() + const em2 = endDate.getUTCMonth() + 1 + const ed2 = endDate.getUTCDate() + // ICAL.Timezone.localTimezone is passed as the zone arg required by TS types. // isDate:true suppresses any time/TZID output regardless of zone. (D-13) const startTime = new ICAL.Time({ year: sy, month: sm, day: sd, isDate: true }, ICAL.Timezone.localTimezone) - const endTime = new ICAL.Time({ year: ey, month: em, day: ed, isDate: true }, ICAL.Timezone.localTimezone) + const endTime = new ICAL.Time({ year: ey2, month: em2, day: ed2, isDate: true }, ICAL.Timezone.localTimezone) vevent.addPropertyWithValue('dtstart', startTime) vevent.addPropertyWithValue('dtend', endTime) } else { From c178dcee0c791a40c6b69bdb33145616cb566d1b Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:50:11 -0400 Subject: [PATCH 3/5] test(03-10): add RED tests for CR-03 fail-closed creds + WR-01 backoff index - Add mockDecryptPassword to vi.hoisted() so tests can control loadClientForUser behavior - Add vi.mock for broker/crypto.js to enable CR-03 scenario - Introduce wireMockChain() helper that differentiates credential vs outbox db selects - CR-03 RED: credential-load failure must leave row pending, not call createFastmailClient('') - WR-01 RED: first transient retry must use BACKOFF_SECONDS[0]=15s not BACKOFF_SECONDS[1]=60s - Update FAKE_CRED_ROW so loadClientForUser can return a real credential-shaped row --- apps/api/tests/broker/outboxWorker.test.ts | 119 +++++++++++++++++---- 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index bef65ae..8b794e9 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -34,6 +34,7 @@ const { mockWherePending, mockFromFn, mockSelectFn, + mockDecryptPassword, } = vi.hoisted(() => { const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet }) @@ -42,11 +43,20 @@ const { const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[])) const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending }) const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn }) - return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn } + // By default returns a dummy password so loadClientForUser succeeds + const mockDecryptPassword = vi.fn().mockReturnValue('app-password') + return { mockUpdateSet, mockUpdate, mockWherePending, mockFromFn, mockSelectFn, mockDecryptPassword } }) let mockPendingRows: unknown[] = [] +// Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where() +const FAKE_CRED_ROW = { + userId: 42, + fastmailEmail: 'test@fastmail.com', + encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}', +} + vi.mock('../../src/db/client.js', () => ({ db: { select: mockSelectFn, @@ -73,6 +83,11 @@ vi.mock('../../src/broker/client.js', () => ({ }), })) +// Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests) +vi.mock('../../src/broker/crypto.js', () => ({ + decryptPassword: mockDecryptPassword, +})) + // Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV) const DEFAULT_FORM_PAYLOAD = JSON.stringify({ title: 'Lunch', @@ -104,17 +119,40 @@ const makeRow = (overrides: Record = {}) => ({ const makeResponse = (status: number): Response => ({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response +// Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW +// This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain. +function wireMockChain() { + mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) + mockUpdate.mockReturnValue({ set: mockUpdateSet }) + // mockFromFn differentiates by table argument: + // - memberCredentials table → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) + // - anything else → returns mockPendingRows (outbox query) + mockFromFn.mockImplementation((table: unknown) => { + // Drizzle table objects have a Symbol.for('drizzle:Name') property and a [Table.Symbol.Name]. + // The safest approach: JSON.stringify often includes the table config name. + let isCred = false + try { + isCred = JSON.stringify(table).includes('member_credentials') + } catch { + // table not serializable — not a credential table + } + return { + where: isCred + ? vi.fn().mockResolvedValue([FAKE_CRED_ROW]) + : mockWherePending, + } + }) + mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) + mockSelectFn.mockReturnValue({ from: mockFromFn }) + // Default: decryptPassword succeeds + mockDecryptPassword.mockReturnValue('app-password') +} + describe('runOutboxDrain — state transitions', () => { beforeEach(() => { vi.clearAllMocks() mockPendingRows = [] - // Restore mock chain after clearAllMocks: - // db.select().from(table).where(and(cond1, cond2)) → Promise - mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) - mockFromFn.mockReturnValue({ where: mockWherePending }) - mockSelectFn.mockReturnValue({ from: mockFromFn }) + wireMockChain() }) it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => { @@ -188,11 +226,7 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => { beforeEach(() => { vi.clearAllMocks() mockPendingRows = [] - mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) - mockFromFn.mockReturnValue({ where: mockWherePending }) - mockSelectFn.mockReturnValue({ from: mockFromFn }) + wireMockChain() }) it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => { @@ -243,11 +277,7 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { beforeEach(() => { vi.clearAllMocks() mockPendingRows = [] - mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) - mockUpdate.mockReturnValue({ set: mockUpdateSet }) - mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows)) - mockFromFn.mockReturnValue({ where: mockWherePending }) - mockSelectFn.mockReturnValue({ from: mockFromFn }) + wireMockChain() }) it('processes the create row BEFORE the delete row when both share a groupId', async () => { @@ -288,3 +318,56 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => { expect(createCall).toBeLessThan(deleteCall) }) }) + +describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingRows = [] + wireMockChain() + }) + + it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => { + // Make decryptPassword throw so loadClientForUser throws + mockDecryptPassword.mockImplementation(() => { throw new Error('bad credentials') }) + + const { createFastmailClient } = await import('../../src/broker/client.js') + mockPendingRows = [makeRow()] + + await runOutboxDrain() + + // The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it) + const updateCalls = mockUpdateSet.mock.calls + const anyStatusChange = updateCalls.some((call) => { + const arg = call[0] as { status?: string } + return arg?.status !== undefined + }) + expect(anyStatusChange).toBe(false) + + // createFastmailClient must NEVER be called with empty-string credentials + const emptyCalls = vi.mocked(createFastmailClient).mock.calls.filter( + ([email, password]) => email === '' || password === '' + ) + expect(emptyCalls.length).toBe(0) + }) + + it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', async () => { + const { createCalendarEvent } = await import('../../src/broker/write.js') + vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500)) + const row = makeRow({ attemptCount: 0 }) + mockPendingRows = [row] + + const beforeDrain = Date.now() + await runOutboxDrain() + const afterDrain = Date.now() + + const setArg = mockUpdateSet.mock.calls[0]?.[0] as { nextAttemptAt?: Date; attemptCount?: number } + expect(setArg?.attemptCount).toBe(1) + + // WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15) + // Allow ±2s for execution overhead + const expectedMinMs = beforeDrain + 14_000 + const expectedMaxMs = afterDrain + 16_000 + expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs) + expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs) + }) +}) From c21b040b364b21e4922ccb1ee7989d3742f52372 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:51:48 -0400 Subject: [PATCH 4/5] feat(03-10): fail closed on bad credentials + fix backoff index + explicit randomUUID (CR-03, WR-01, WR-08) - outboxWorker: remove empty-credential fallback; let loadClientForUser throw on error (CR-03) - outboxWorker: fix backoff index from nextAttemptCount to row.attemptCount so first retry waits 15s not 60s (WR-01) - events.ts: replace bare crypto.randomUUID() with import { randomUUID } from 'node:crypto' on all three handlers (WR-08) --- apps/api/src/broker/outboxWorker.ts | 20 +++++++------------- apps/api/src/routes/events.ts | 7 ++++--- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index d0eaf82..afbeaa1 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -130,18 +130,10 @@ interface DispatchResult { } async function dispatchRow(row: OutboxRow): Promise { - // Load the authenticated client for this row's owner. - // In test environments loadClientForUser may fail (db mock mismatch) — fall back - // to createFastmailClient with empty credentials (mocked in tests to return fake client). - let client: FastmailClient - try { - client = await loadClientForUser(row.userId) - } catch { - // Unit-test path: db mock returns outbox rows for any select → decryptPassword throws. - // createFastmailClient is mocked and ignores credentials, so this still works. - // Production path: this branch is never taken (real Drizzle query succeeds). - client = await createFastmailClient('', '') - } + // CR-03: fail closed on credential errors — let loadClientForUser throw. + // The outer per-row catch in runOutboxDrain logs and leaves the row pending (correct transient behavior). + // Do NOT add an empty-credential fallback — that would silently PUT with no authentication. + const client = await loadClientForUser(row.userId) let response: Response @@ -364,7 +356,9 @@ export async function runOutboxDrain(): Promise { failedCreateGroups.add(row.groupId) } } else { - const backoffMs = (BACKOFF_SECONDS[nextAttemptCount] ?? 1800) * 1000 + // WR-01: use row.attemptCount (the attempt that just failed, 0-based) as the backoff index. + // This makes the first retry wait BACKOFF_SECONDS[0]=15s, not BACKOFF_SECONDS[1]=60s. + const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000 await db .update(calendarOutbox) .set({ diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index ae6e743..1b0e5fc 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -21,6 +21,7 @@ * Mounted under /api/* in index.ts — behind oidcAuthMiddleware. */ +import { randomUUID } from 'node:crypto' import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' @@ -253,7 +254,7 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => } // Generate a UID for the new event (Node.js 22 built-in) - const uid = `${crypto.randomUUID()}@familysync` + const uid = `${randomUUID()}@familysync` // Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail. await db.insert(calendarOutbox).values({ @@ -325,8 +326,8 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c if (isCalendarMove) { // D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5) - const newUid = `${crypto.randomUUID()}@familysync` - const groupId = crypto.randomUUID() + const newUid = `${randomUUID()}@familysync` + const groupId = randomUUID() await db.transaction(async (tx) => { // Delete from old calendar From aefdde13bd59b4767423d96e8bb2df3f0a5d95aa Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 20:53:04 -0400 Subject: [PATCH 5/5] docs(03-10): complete outbox ICS builder wiring plan summary CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed. --- .../03-10-SUMMARY.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md diff --git a/.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md b/.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md new file mode 100644 index 0000000..8f8ec48 --- /dev/null +++ b/.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 03-event-write-back-pwa-install +plan: "10" +subsystem: api-broker +tags: [tdd, gap-closure, ics-builder, outbox-worker, vevent, rfc5545, credentials] +dependency_graph: + requires: + - 03-09 (canonical title/start/end form JSON shape in calendarOutbox payload) + provides: + - ics-builder-wired-to-dispatch (outboxWorker calls buildVeventString for create/update) + - wR04-dtend-plus-one (vevent.ts all-day DTEND exclusive RFC-5545 fix) + - cr03-fail-closed-credentials (outbox never PUTs with empty auth) + - wR01-backoff-15s-first (first retry waits 15s not 60s) + affects: + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/broker/vevent.ts + - apps/api/src/routes/events.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/broker/vevent.test.ts +tech_stack: + added: [] + patterns: + - "TDD RED→GREEN per task" + - "vi.hoisted() + per-test crypto mock for loadClientForUser failure scenarios" + - "Table-differentiated db select mock (credential vs outbox queries)" +decisions: + - "WR-04 owning boundary is vevent.ts only — form/routes pass inclusive end unchanged" + - "CR-03: loadClientForUser throws propagate to outer catch (row stays pending); no empty-cred fallback" + - "WR-01: backoff index is row.attemptCount (the failed attempt, 0-based) not nextAttemptCount" +key_files: + modified: + - apps/api/src/broker/outboxWorker.ts + - apps/api/src/broker/vevent.ts + - apps/api/src/routes/events.ts + - apps/api/tests/broker/outboxWorker.test.ts + - apps/api/tests/broker/vevent.test.ts +metrics: + duration_minutes: 6 + completed_date: "2026-06-06" + tasks_completed: 2 + files_modified: 5 +--- + +# Phase 03 Plan 10: Outbox Worker ICS Builder Wiring Summary + +Wire the VEVENT builder into the outbox worker dispatch path, pin the D-13 DATE/DATETIME contract and exclusive all-day DTEND with a direct unit test, and fix three correctness defects: empty-credential PUT fallback (CR-03), wrong backoff index (WR-01), and bare crypto.randomUUID() call (WR-08). + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 RED | Add D-13 contract + ICS wiring test (vevent + worker) | 813a7ba | vevent.test.ts, outboxWorker.test.ts | +| 1 GREEN | Wire buildVeventString, fix all-day DTEND+1 (CR-02, WR-04) | c03b479 | outboxWorker.ts, vevent.ts | +| 2 RED | Add CR-03 + WR-01 RED tests (crypto mock, backoff timing) | c178dce | outboxWorker.test.ts | +| 2 GREEN | Fail closed on bad creds, fix backoff index, explicit randomUUID | c21b040 | outboxWorker.ts, events.ts | + +## Verification + +- `cd apps/api && npx vitest run tests/broker/` — 51/51 pass (7 files) +- `cd apps/api && npm run build` — clean TypeScript compile +- `grep -c 'buildVeventString' apps/api/src/broker/outboxWorker.ts` — 3 (import + 2 call sites, IN-01 closed) +- `grep -c 'D-13 form-parsed contract' apps/api/tests/broker/vevent.test.ts` — 1 +- `grep -c "createFastmailClient('', '')" apps/api/src/broker/outboxWorker.ts` — 0 (CR-03 closed) +- `grep -c "import { randomUUID } from 'node:crypto'" apps/api/src/routes/events.ts` — 1 (WR-08 closed) +- `grep -c 'crypto.randomUUID(' apps/api/src/routes/events.ts` — 0 + +## Decisions Made + +- **WR-04 owning boundary**: The RFC-5545 exclusive DTEND (+1 day for all-day events) is fixed in `vevent.ts` only. The form/route layer continues passing the user-entered inclusive end date unchanged. This is correct because `vevent.ts` is the single serialization point for all write paths — fixing it there covers all callers. +- **CR-03 approach**: Removed the `try/catch` fallback that called `createFastmailClient('', '')`. `loadClientForUser` now throws naturally; the outer per-row `catch` in `runOutboxDrain` logs the error and leaves the row `pending` — it will be retried on the next drain cycle when credentials are available. +- **WR-01 index correction**: Changed `BACKOFF_SECONDS[nextAttemptCount]` to `BACKOFF_SECONDS[row.attemptCount]`. `row.attemptCount` is the attempt that just failed (0-indexed), so the first failure uses index 0 = 15s. `nextAttemptCount` is persisted as the new `attemptCount` value. + +## TDD Gate Compliance + +Both tasks followed strict RED→GREEN: +- Task 1: `test(03-10)` commit (813a7ba) → `feat(03-10)` commit (c03b479) +- Task 2: `test(03-10)` commit (c178dce) → `feat(03-10)` commit (c21b040) + +RED confirmed failing for correct reasons before each GREEN commit: +- Task 1 RED: vevent DTEND=20260610 not 20260611; worker passed raw JSON not BEGIN:VCALENDAR +- Task 2 RED: CR-03 worker updated row to 'done' via empty-cred path; WR-01 backoff was 60s not 15s + +## Deviations from Plan + +### Auto-fixed Issues + +None — plan executed exactly as written. + +### Infrastructure + +The worktree lacks `node_modules`. Created `apps/api/node_modules` symlink pointing to the main repo's `apps/api/node_modules` (standard pnpm-workspace + git-worktree pattern, same as 03-09). + +The existing db mock in `outboxWorker.test.ts` returned the same rows for any `db.select().from(anyTable)` call. After removing the empty-cred fallback (CR-03), `loadClientForUser` needed the db mock to return a proper credential row when called with `memberCredentials`. Extended `mockFromFn` to distinguish the two tables via `JSON.stringify(table).includes('member_credentials')` and introduced a `wireMockChain()` helper shared across all describe blocks. + +## Issues Closed + +| ID | Description | +|----|-------------| +| CR-02 | Worker was passing raw form JSON to CalDAV PUT — now builds VCALENDAR via buildVeventString | +| CR-03 | Worker fell back to empty-cred createFastmailClient on any credential error — removed fallback | +| WR-01 | First transient retry used BACKOFF_SECONDS[1]=60s instead of BACKOFF_SECONDS[0]=15s — fixed index | +| WR-04 | All-day events emitted DTEND = DTSTART (no +1 day) — fixed in vevent.ts (owning boundary) | +| WR-08 | events.ts used bare crypto.randomUUID() — replaced with import { randomUUID } from 'node:crypto' | +| IN-01 | buildVeventString was dead code (never called outside vevent.ts) — now has 2 live call sites | + +## Known Stubs + +None. All changes are functional code. The worker now builds real RFC-5545 VCALENDAR strings from stored form JSON. + +## Threat Flags + +No new network endpoints, auth paths, or schema changes. The CR-03 fix improves security posture by ensuring the worker never PUTs with empty Basic-auth credentials. + +## Self-Check: PASSED + +- apps/api/src/broker/outboxWorker.ts: FOUND +- apps/api/src/broker/vevent.ts: FOUND +- apps/api/src/routes/events.ts: FOUND +- apps/api/tests/broker/outboxWorker.test.ts: FOUND +- apps/api/tests/broker/vevent.test.ts: FOUND +- 813a7ba (test RED task1): FOUND +- c03b479 (feat GREEN task1): FOUND +- c178dce (test RED task2): FOUND +- c21b040 (feat GREEN task2): FOUND