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) + }) +})