Files
accounted/extensions/general/whatsapp-inbox/__tests__/linking.test.ts
T
MattssonandClaude Fable 5.1 1a725c121a fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel (#2349)
* 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>
2026-09-06 20:14:06 +02:00

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