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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user