Files
accounted/tests/pg/whatsapp-channel.pg.test.ts
T
bd3592cb99 feat(db): WhatsApp channel schema (phone links, messages, inbox source) (#1337)
Foundation for WhatsApp receipt intake (plan 2026-08-01), no runtime
callers yet:

- whatsapp_phone_links + whatsapp_link_codes: verified phone -> user
  binding via single-use 10-min codes (invite-token pattern). Peppered
  HMAC lookup hash + AES-GCM encrypted number; one ACTIVE link per
  phone and per user (partial unique, revocation preserves history).
  User-scoped RLS (a binding belongs to a person, not a company).
- whatsapp_conversations + whatsapp_messages: deterministic state
  machine state and the message log, which doubles as the durable job
  record for persist-first webhook processing. Partial unique index on
  inbound wamid = the at-least-once dedupe key. Service-role only.
- whatsapp_sender_rate_counters + check_and_increment_whatsapp_sender_quota:
  the pre-binding limiter keyed by phone hash; EXECUTE granted to
  service_role only.
- invoice_inbox_items: source CHECK widened to 'whatsapp', plus
  whatsapp_message_id (one item per delivering message) and
  channel_context jsonb, kept separate from extracted_data so verified
  human answers never share a container with untrusted OCR output.
  document_attachments.upload_source CHECK gains 'whatsapp'.
- whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES
  (full-archive coverage contract).

pg-real on a fresh DB: 975/975 incl. the new whatsapp-channel suite
(RLS visibility, unique/rebinding semantics, wamid dedupe, CHECK
widenings, quota RPC caps + grant lockdown).

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

290 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID, createHash } from 'node:crypto'
import { describe, it, expect, beforeAll } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser } from './fixtures'
// Migrations under test: 20260802090000 (phone links + link codes),
// 20260802091000 (conversations + messages + sender quota RPC),
// 20260802092000 (inbox source CHECK widening + channel_context).
function hash(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
async function insertPhoneLink(params: {
userId: string
phoneHash?: string
revokedAt?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.whatsapp_phone_links
(id, user_id, phone_hash, phone_enc, phone_masked, revoked_at)
VALUES ($1, $2, $3, '\\x00', '+46 70 *** ** 00', $4)`,
[id, params.userId, params.phoneHash ?? hash(randomUUID()), params.revokedAt ?? null],
)
return id
}
describe('whatsapp_phone_links', () => {
let userA: string
let userB: string
let linkA: string
beforeAll(async () => {
userA = await insertAuthUser()
userB = await insertAuthUser()
linkA = await insertPhoneLink({ userId: userA })
await insertPhoneLink({ userId: userB })
})
it('lets a user read only their own link', async () => {
const rows = await withUserContext(userA, async (client) => {
const res = await client.query(`SELECT id, user_id FROM public.whatsapp_phone_links`)
return res.rows
})
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe(linkA)
})
it('lets a user update their own link but blocks INSERT (service-role only)', async () => {
await withUserContext(userA, async (client) => {
const upd = await client.query(
`UPDATE public.whatsapp_phone_links SET muted_at = now() WHERE id = $1 RETURNING id`,
[linkA],
)
expect(upd.rows).toHaveLength(1)
})
await expect(
withUserContext(userA, async (client) => {
await client.query(
`INSERT INTO public.whatsapp_phone_links
(user_id, phone_hash, phone_enc, phone_masked)
VALUES ($1, $2, '\\x00', '+46 70 *** ** 11')`,
[userA, hash('self-insert')],
)
}),
).rejects.toThrow(/row-level security/)
})
it('cannot update another user’s link', async () => {
const updated = await withUserContext(userB, async (client) => {
const res = await client.query(
`UPDATE public.whatsapp_phone_links SET muted_at = now() WHERE id = $1 RETURNING id`,
[linkA],
)
return res.rows
})
expect(updated).toHaveLength(0)
})
it('enforces one ACTIVE link per phone, allowing rebinding after revocation', async () => {
// Unique per run: pool inserts commit, so a fixed hash would collide
// with rows left behind by a previous suite run against the same DB.
const phoneHash = hash(`shared-phone-${randomUUID()}`)
const owner1 = await insertAuthUser()
const owner2 = await insertAuthUser()
await insertPhoneLink({ userId: owner1, phoneHash })
await expect(insertPhoneLink({ userId: owner2, phoneHash })).rejects.toThrow(
/whatsapp_phone_links_phone_active/,
)
await getPool().query(
`UPDATE public.whatsapp_phone_links SET revoked_at = now() WHERE phone_hash = $1`,
[phoneHash],
)
await expect(insertPhoneLink({ userId: owner2, phoneHash })).resolves.toBeTruthy()
})
it('enforces one ACTIVE link per user', async () => {
const owner = await insertAuthUser()
await insertPhoneLink({ userId: owner })
await expect(insertPhoneLink({ userId: owner })).rejects.toThrow(
/whatsapp_phone_links_user_active/,
)
})
})
describe('whatsapp_link_codes / conversations / messages are service-role only', () => {
it('hides link codes, conversations and messages from authenticated users', async () => {
const { userId } = await seedCompany()
const linkId = await insertPhoneLink({ userId })
await getPool().query(
`INSERT INTO public.whatsapp_link_codes (user_id, code_hash, expires_at)
VALUES ($1, $2, now() + interval '10 minutes')`,
[userId, hash(randomUUID())],
)
await getPool().query(
`INSERT INTO public.whatsapp_conversations (phone_link_id) VALUES ($1)`,
[linkId],
)
await getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, phone_link_id)
VALUES ('inbound', 'text', $1)`,
[linkId],
)
await withUserContext(userId, async (client) => {
for (const table of [
'whatsapp_link_codes',
'whatsapp_conversations',
'whatsapp_messages',
]) {
const res = await client.query(`SELECT count(*)::int AS n FROM public.${table}`)
expect(res.rows[0].n).toBe(0)
}
})
})
})
describe('whatsapp_messages wamid idempotency', () => {
it('rejects duplicate INBOUND wamids but allows outbound reuse', async () => {
const wamid = `wamid.${randomUUID()}`
await getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, wamid)
VALUES ('inbound', 'image', $1)`,
[wamid],
)
await expect(
getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, wamid)
VALUES ('inbound', 'image', $1)`,
[wamid],
),
).rejects.toThrow(/whatsapp_messages_inbound_wamid/)
// ON CONFLICT DO NOTHING over the partial index is the webhook's dedupe.
const dedupe = await getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, wamid)
VALUES ('inbound', 'image', $1)
ON CONFLICT (wamid) WHERE wamid IS NOT NULL AND direction = 'inbound'
DO NOTHING
RETURNING id`,
[wamid],
)
expect(dedupe.rows).toHaveLength(0)
// The partial index does not constrain outbound rows.
await expect(
getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, wamid)
VALUES ('outbound', 'text', $1)`,
[wamid],
),
).resolves.toBeTruthy()
})
})
describe('inbox source widening (20260802092000)', () => {
it('accepts source=whatsapp with channel_context and links the message', async () => {
const { userId, companyId } = await seedCompany()
const linkId = await insertPhoneLink({ userId })
const msg = await getPool().query(
`INSERT INTO public.whatsapp_messages (direction, message_type, phone_link_id)
VALUES ('inbound', 'image', $1) RETURNING id`,
[linkId],
)
const messageId = msg.rows[0].id
const item = await getPool().query(
`INSERT INTO public.invoice_inbox_items
(company_id, user_id, status, source, whatsapp_message_id, channel_context, extracted_data)
VALUES ($1, $2, 'received', 'whatsapp', $3, $4, '{}')
RETURNING id, source, channel_context`,
[
companyId,
userId,
messageId,
JSON.stringify({ channel: 'whatsapp', caption: 'lunch med kund' }),
],
)
expect(item.rows[0].source).toBe('whatsapp')
expect(item.rows[0].channel_context.caption).toBe('lunch med kund')
// One inbox item per delivering chat message.
await expect(
getPool().query(
`INSERT INTO public.invoice_inbox_items
(company_id, user_id, status, source, whatsapp_message_id, extracted_data)
VALUES ($1, $2, 'received', 'whatsapp', $3, '{}')`,
[companyId, userId, messageId],
),
).rejects.toThrow(/invoice_inbox_items_whatsapp_msg/)
})
it('still rejects unknown sources (the widened CHECK actually replaced the old one)', async () => {
const { userId, companyId } = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.invoice_inbox_items
(company_id, user_id, status, source, extracted_data)
VALUES ($1, $2, 'received', 'carrier_pigeon', '{}')`,
[companyId, userId],
),
).rejects.toThrow(/invoice_inbox_items_source_check/)
})
it('accepts upload_source=whatsapp on document_attachments and rejects unknown values', async () => {
const { userId, companyId } = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.document_attachments
(user_id, company_id, file_name, mime_type, file_size_bytes,
storage_path, sha256_hash, upload_source)
VALUES ($1, $2, 'kvitto.jpg', 'image/jpeg', 1024,
$3, $4, 'whatsapp')`,
[userId, companyId, `documents/${companyId}/${userId}/t_kvitto.jpg`, hash(randomUUID())],
),
).resolves.toBeTruthy()
await expect(
getPool().query(
`INSERT INTO public.document_attachments
(user_id, company_id, file_name, mime_type, file_size_bytes,
storage_path, sha256_hash, upload_source)
VALUES ($1, $2, 'kvitto.jpg', 'image/jpeg', 1024,
$3, $4, 'telegram')`,
[userId, companyId, `documents/${companyId}/${userId}/t2_kvitto.jpg`, hash(randomUUID())],
),
).rejects.toThrow(/document_attachments_upload_source_check/)
})
})
describe('check_and_increment_whatsapp_sender_quota', () => {
it('counts per phone hash and trips the minute cap with rollback semantics', async () => {
const phoneHash = hash(`quota-${randomUUID()}`)
const call = () =>
getPool().query(`SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100) AS r`, [
phoneHash,
])
expect((await call()).rows[0].r.ok).toBe(true)
expect((await call()).rows[0].r.ok).toBe(true)
const third = (await call()).rows[0].r
expect(third.ok).toBe(false)
expect(third.scope).toBe('minute')
// A different sender is unaffected: the key is the phone hash.
const other = await getPool().query(
`SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100) AS r`,
[hash(`other-${randomUUID()}`)],
)
expect(other.rows[0].r.ok).toBe(true)
})
it('is not executable by authenticated users (service-role only)', async () => {
const userId = await insertAuthUser()
await expect(
withUserContext(userId, async (client) => {
await client.query(
`SELECT public.check_and_increment_whatsapp_sender_quota($1, 2, 100)`,
[hash('nope')],
)
}),
).rejects.toThrow(/permission denied/)
})
})