Files
accounted/extensions/general/whatsapp-inbox/__tests__/linking.test.ts
T
398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:47:08 +02:00

174 lines
6.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
CODE_ALPHABET,
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 sha256 hex of the code', () => {
const expected = crypto.createHash('sha256').update('AC-7KP4QF').digest('hex')
expect(hashLinkCode('AC-7KP4QF')).toBe(expected)
})
})
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({ 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({ data: null, error: { message: 'boom' } })
await expect(
mintLinkCode(supabase as unknown as SupabaseClient, 'user-1'),
).rejects.toThrow(/boom/)
})
})
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('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)
})
})
})