merge(03-10): outbox real VEVENT dispatch + fail-closed creds (CR-02/03, WR-01/04/08)
This commit is contained in:
@@ -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
|
||||
@@ -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) ────────────────────────────────────────────────────────
|
||||
@@ -129,18 +130,10 @@ interface DispatchResult {
|
||||
}
|
||||
|
||||
async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
// 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
|
||||
|
||||
@@ -165,10 +158,27 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
error: 'update operation missing payload or calendarObjectUrl',
|
||||
}
|
||||
}
|
||||
// CR-02: parse the stored form JSON and build a real VCALENDAR string
|
||||
let fields: Record<string, unknown>
|
||||
try {
|
||||
fields = JSON.parse(row.payload) as Record<string, unknown>
|
||||
} 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 +192,26 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
||||
error: 'create operation missing payload',
|
||||
}
|
||||
}
|
||||
// CR-02: parse the stored form JSON and build a real VCALENDAR string
|
||||
let fields: Record<string, unknown>
|
||||
try {
|
||||
fields = JSON.parse(row.payload) as Record<string, unknown>
|
||||
} 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<typeof createCalendarEvent>[1]
|
||||
response = await createCalendarEvent(client, davCalendar, row.uid, row.payload)
|
||||
response = await createCalendarEvent(client, davCalendar, row.uid, icsString)
|
||||
}
|
||||
|
||||
const status = response.status
|
||||
@@ -329,7 +356,9 @@ export async function runOutboxDrain(): Promise<void> {
|
||||
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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,20 @@ 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',
|
||||
allDay: false,
|
||||
start: '2026-06-10T12:00:00',
|
||||
end: '2026-06-10T13:00:00',
|
||||
recurrence: 'none',
|
||||
})
|
||||
|
||||
const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
userId: 42,
|
||||
@@ -82,7 +106,7 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
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,
|
||||
@@ -95,17 +119,40 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
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<rows>
|
||||
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 () => {
|
||||
@@ -175,15 +222,62 @@ describe('runOutboxDrain — state transitions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
|
||||
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()
|
||||
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 () => {
|
||||
@@ -224,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user