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,
|
mockWherePending,
|
||||||
mockFromFn,
|
mockFromFn,
|
||||||
mockSelectFn,
|
mockSelectFn,
|
||||||
|
mockDecryptPassword,
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||||
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
||||||
@@ -42,11 +43,20 @@ const {
|
|||||||
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
||||||
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
||||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
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[] = []
|
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', () => ({
|
vi.mock('../../src/db/client.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: mockSelectFn,
|
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)
|
// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV)
|
||||||
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
||||||
title: 'Lunch',
|
title: 'Lunch',
|
||||||
@@ -104,17 +119,40 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
|||||||
const makeResponse = (status: number): Response =>
|
const makeResponse = (status: number): Response =>
|
||||||
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as 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', () => {
|
describe('runOutboxDrain — state transitions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
// Restore mock chain after clearAllMocks:
|
wireMockChain()
|
||||||
// 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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
wireMockChain()
|
||||||
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 () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockPendingRows = []
|
mockPendingRows = []
|
||||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
wireMockChain()
|
||||||
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
|
||||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
|
||||||
mockFromFn.mockReturnValue({ where: mockWherePending })
|
|
||||||
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('processes the create row BEFORE the delete row when both share a groupId', async () => {
|
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)
|
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