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
This commit is contained in:
Lucas Berger
2026-06-05 20:50:11 -04:00
parent c03b47938e
commit c178dcee0c
+101 -18
View File
@@ -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<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 () => {
@@ -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)
})
})