* fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel Sends now say HOW they failed (failure: http_rejected | transport_error), and the company question falls back to numbered text only on an HTTP rejection. A timeout means Meta may already have delivered the interactive question, so that case rolls the question back instead of putting a second copy on the phone; the next receipt re-asks. Both drains (the company answer and the single-live-company path) share one helper that stamps rows older than Meta's ~30-day media retention as company_choice_expired instead of re-opening them into the MAX_ATTEMPTS error path, and tell the sender once (M20) how many receipts could not be recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep and the drains read one definition. POST /link/default-company checks LIVE membership with the same companies!inner(archived_at) filter intake uses, so a default pointing at an archived company is refused (403) instead of saved and then silently ignored; a failed membership read is a 500, not a 403. consumeLinkCode is tri-state: a failed lookup or claim returns 'transient_error' and the webhook answers with a neutral retry (M21) instead of M2 "the code is wrong" to a user holding a valid code. In degraded mode (quota RPC down too) M21 sits behind the same fail-closed throttle as M2. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle Review pass on #2349 (CodeRabbit, four findings, one commit): - drainParkedRows reads the error of both UPDATEs, logs each, and returns failed: true. The answer stays applied (pin set, options claimed) and the single-company path still clears the dead question: both turn the rows that are still parked into orphans, and a new sweep pass re-opens parked rows whose conversation has no open company question (older than two minutes, so a row parked just before its question is armed is left alone) through the same drain and processes them. - badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a lookup outage cannot earn one M21 per message in degraded mode. - The M20 expiry notice logs a failed send. It stays best-effort like M17, M18 and M19: the extension has no durable outbound retry, and the rows the notice describes are already terminal. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): type the orphan scan rows and the cron summary fixture reopenedOrphans joined SweepSummary in the previous commit; the cron route test's fixture and the PostgREST embed cast in the orphan pass had not followed (check:types caught both). Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
233 lines
9.1 KiB
TypeScript
233 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import crypto from 'crypto'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import {
|
|
CODE_ALPHABET,
|
|
LinkCodeRateLimitError,
|
|
MAX_CODES_PER_TTL_WINDOW,
|
|
normalizeLinkCode,
|
|
looksLikeLinkCode,
|
|
hashLinkCode,
|
|
mintLinkCode,
|
|
consumeLinkCode,
|
|
} from '@/extensions/general/whatsapp-inbox/lib/linking'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
describe('linking', () => {
|
|
const originalEnv = { ...process.env }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
|
|
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
|
|
})
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv }
|
|
})
|
|
|
|
describe('normalizeLinkCode', () => {
|
|
it('accepts the canonical form', () => {
|
|
expect(normalizeLinkCode('AC-7KP4QF')).toBe('AC-7KP4QF')
|
|
})
|
|
|
|
it('uppercases and trims', () => {
|
|
expect(normalizeLinkCode(' ac-7kp4qf ')).toBe('AC-7KP4QF')
|
|
})
|
|
|
|
it('tolerates a missing prefix (when digits disambiguate) and inner whitespace', () => {
|
|
expect(normalizeLinkCode('7KP4QF')).toBe('AC-7KP4QF')
|
|
expect(normalizeLinkCode('AC 7KP4QF')).toBe('AC-7KP4QF')
|
|
// All-letter bodies are only accepted WITH the prefix: bare ones would
|
|
// collide with ordinary words.
|
|
expect(normalizeLinkCode('AC-QSTVWX')).toBe('AC-QSTVWX')
|
|
expect(normalizeLinkCode('QSTVWX')).toBeNull()
|
|
})
|
|
|
|
it('rejects ambiguous characters outside the alphabet', () => {
|
|
// 1, 0, I, O, L, U are excluded from the alphabet.
|
|
expect(normalizeLinkCode('AC-7KP4Q1')).toBeNull()
|
|
expect(normalizeLinkCode('AC-7KP4QO')).toBeNull()
|
|
})
|
|
|
|
it('rejects free text and wrong lengths', () => {
|
|
expect(normalizeLinkCode('hej hej')).toBeNull()
|
|
expect(normalizeLinkCode('hejhej')).toBeNull()
|
|
expect(normalizeLinkCode('AC-7KP4Q')).toBeNull()
|
|
expect(normalizeLinkCode('AC-7KP4QFF')).toBeNull()
|
|
expect(normalizeLinkCode('')).toBeNull()
|
|
expect(normalizeLinkCode(null)).toBeNull()
|
|
})
|
|
|
|
it('looksLikeLinkCode mirrors normalizeLinkCode', () => {
|
|
expect(looksLikeLinkCode('ac-7kp4qf')).toBe(true)
|
|
expect(looksLikeLinkCode('lunch med anna')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('hashLinkCode', () => {
|
|
it('is an HMAC under the server pepper, never a bare sha256', () => {
|
|
// 30^6 codes behind a fixed 'AC-' prefix is enumerable offline in about
|
|
// a second, so an unpeppered digest would protect nothing at rest.
|
|
const bare = crypto.createHash('sha256').update('AC-7KP4QF').digest('hex')
|
|
const peppered = crypto
|
|
.createHmac('sha256', Buffer.from('test-pepper', 'utf8'))
|
|
.update('AC-7KP4QF')
|
|
.digest('hex')
|
|
expect(hashLinkCode('AC-7KP4QF')).toBe(peppered)
|
|
expect(hashLinkCode('AC-7KP4QF')).not.toBe(bare)
|
|
})
|
|
|
|
it('changes with the pepper', () => {
|
|
const withA = hashLinkCode('AC-7KP4QF')
|
|
process.env.WHATSAPP_PHONE_HASH_KEY = 'another-pepper'
|
|
expect(hashLinkCode('AC-7KP4QF')).not.toBe(withA)
|
|
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
|
|
})
|
|
})
|
|
|
|
describe('mintLinkCode', () => {
|
|
it('mints an AC- prefixed code from the ambiguity-free alphabet with a 10 min TTL', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ count: 0 }) // mint-rate check
|
|
enqueue({ data: null, error: null }) // burn earlier unused codes
|
|
enqueue({ data: null, error: null })
|
|
|
|
const before = Date.now()
|
|
const minted = await mintLinkCode(supabase as unknown as SupabaseClient, 'user-1')
|
|
|
|
expect(minted.code).toMatch(/^AC-[A-Z2-9]{6}$/)
|
|
for (const ch of minted.code.slice(3)) {
|
|
expect(CODE_ALPHABET).toContain(ch)
|
|
}
|
|
const ttlMs = new Date(minted.expiresAt).getTime() - before
|
|
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000)
|
|
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5000)
|
|
|
|
const insertArgs = findCall('whatsapp_link_codes', 'insert') as [Record<string, unknown>]
|
|
expect(insertArgs[0].user_id).toBe('user-1')
|
|
expect(insertArgs[0].code_hash).toBe(hashLinkCode(minted.code))
|
|
})
|
|
|
|
it('throws when the insert fails', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ count: 0 })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: null, error: { message: 'boom' } })
|
|
await expect(
|
|
mintLinkCode(supabase as unknown as SupabaseClient, 'user-1'),
|
|
).rejects.toThrow(/boom/)
|
|
})
|
|
|
|
it('burns the caller earlier unused codes so only the newest one works', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ count: 1 })
|
|
enqueue({ data: null, error: null })
|
|
enqueue({ data: null, error: null })
|
|
|
|
await mintLinkCode(supabase as unknown as SupabaseClient, 'user-1')
|
|
|
|
const burn = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
|
|
expect(burn[0].used_at).toBeTruthy()
|
|
})
|
|
|
|
it('refuses to mint once the caller has burned through the window quota', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ count: MAX_CODES_PER_TTL_WINDOW })
|
|
|
|
await expect(
|
|
mintLinkCode(supabase as unknown as SupabaseClient, 'user-1'),
|
|
).rejects.toBeInstanceOf(LinkCodeRateLimitError)
|
|
})
|
|
})
|
|
|
|
describe('consumeLinkCode', () => {
|
|
const futureExpiry = () => new Date(Date.now() + 5 * 60 * 1000).toISOString()
|
|
|
|
it('consumes a valid unused code', async () => {
|
|
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
|
|
enqueue({ data: { id: 'code-1' } })
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'ac-7kp4qf')
|
|
expect(result).toEqual({ userId: 'user-1' })
|
|
const updateArgs = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
|
|
expect(updateArgs[0].used_at).toBeTruthy()
|
|
})
|
|
|
|
it('rejects an expired code', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: {
|
|
id: 'code-1',
|
|
user_id: 'user-1',
|
|
expires_at: new Date(Date.now() - 1000).toISOString(),
|
|
used_at: null,
|
|
},
|
|
})
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBeNull()
|
|
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
|
|
})
|
|
|
|
it('rejects an already-used code (single use)', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: {
|
|
id: 'code-1',
|
|
user_id: 'user-1',
|
|
expires_at: futureExpiry(),
|
|
used_at: new Date().toISOString(),
|
|
},
|
|
})
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBeNull()
|
|
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
|
|
})
|
|
|
|
it('loses the claim race gracefully', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
|
|
enqueue({ data: null }) // guarded update matched no row: someone else won
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('rejects unknown codes without touching anything', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: null })
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBeNull()
|
|
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
|
|
})
|
|
|
|
it('reports a failed lookup as transient, not as a bad code', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ error: { message: 'connection reset' } })
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBe('transient_error')
|
|
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
|
|
})
|
|
|
|
it('reports a failed claim as transient: the code stays claimable', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
|
|
enqueue({ error: { message: 'canceling statement due to statement timeout' } })
|
|
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
|
|
expect(result).toBe('transient_error')
|
|
})
|
|
|
|
it('short-circuits on non-code text without any DB call', async () => {
|
|
const { supabase, calls } = createQueuedMockSupabase()
|
|
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'hej!')
|
|
expect(result).toBeNull()
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
})
|
|
})
|