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>
174 lines
6.5 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|