Files
accounted/extensions/general/email/__tests__/sending-domain-routes.test.ts
T
MattssonandClaude Fable 5 0040cadacc feat(invoicing): opt-in invoice email from the company's own sending domain (#1802)
* feat(invoicing): opt-in invoice email from the company's own sending domain

Companies holding the custom_sender_domain capability grant can register
their own domain (Resend sending-only profile), publish DKIM/SPF, and once
verified every invoice email (send, reminders, recurring, payment
confirmation, MCP/v1 sends) leaves as "<name> <faktura@their-domain>"
instead of the platform sender. Reply-To is unchanged.

- New table company_sending_domains (RLS: members read, owner/admin write;
  audit trigger), types, archive-export classification.
- New capability key custom_sender_domain: manually granted per company,
  deliberately outside PAID_CAPABILITIES (never trial-seeded, never written
  by the Stripe sync). Without the grant the settings section is hidden and
  nothing changes.
- Email extension: sending-domain routes (GET/POST/PATCH/DELETE, verify),
  Resend domain lifecycle without orphan adoption, domain.updated handling
  on the delivery webhook, explicit From support in the Resend adapter.
- Core resolveInvoiceSender(): verified + enabled + entitled, else the
  platform sender; never throws.
- Settings -> Invoicing: "Avsändare vid fakturautskick" section (sv/en).
- Unit tests for the resolver, domain helpers, routes, From header; pg-real
  test for RLS and constraints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): harden sending-domain writes, sender fallback, review findings

Skeptic refutations:
- Tenant JWTs could insert/update company_sending_domains with status =
  'verified' and an arbitrary domain through PostgREST (RLS only checked
  membership), then send invoice mail as that domain. New migration
  20260822130000 adds a BEFORE trigger: tenants may only open a pending
  claim and edit sender_local_part/sender_name/enabled; domain and
  verification state are service-role only. claim/verify helpers now take
  a service-role writer for those columns; the route's RLS client still
  does the insert.
- A company domain Resend later rejects made every invoice send fail: the
  Resend adapter retries once as the platform sender when an explicit
  company From is rejected (nothing was sent, so no double send).

Review findings:
- domain.updated webhook: discriminated outcome; DB errors answer 500 so
  Svix retries, unknown domains are acknowledged.
- Display names are RFC 5322-quoted only when they carry specials.
- Sender local part is a strict dot-atom (no trailing/consecutive dots),
  in code and in the CHECK constraint; resend_domain_id index is UNIQUE.
- IME composition guard on the claim input; event bus reset in tests;
  settings section skips its request for non-admins.

Deferred (needs a product call): persisting the effective From address in
the invoice delivery log touches the hardened evidence triggers; recorded
in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoicing): bind sending-domain verification to the claimed domain; fix pg test

Skeptic re-check found a TOCTOU: during the claim's Resend round-trip a
tenant could delete and re-insert its pending row under the same id with a
reserved domain, and the service-role writer updated by id alone. Now:
- the claim's verification-state write filters on (id, company_id, domain,
  resend_domain_id IS NULL) and rolls back on zero rows;
- verify and the domain.updated webhook compare Resend's domain name with
  the row before writing verified;
- resolveInvoiceSender refuses reserved platform domains and non-hostnames
  at send time (reserved-domain logic moved to lib/email/domain-name.ts and
  shared with the claim validator).

pg-real: the case-insensitive uniqueness assertion now expects the
domain_shape CHECK (lowercase enforced) for an uppercase variant and the
unique index for a same-case duplicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:07:30 +02:00

279 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { emailExtension } from '@/extensions/general/email'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
const claimMock = vi.fn()
const verifyMock = vi.fn()
const removeMock = vi.fn()
const getMock = vi.fn()
const updateMock = vi.fn()
const webhookApplyMock = vi.fn()
vi.mock('@/extensions/general/email/lib/sending-domains', () => ({
claimSendingDomain: (...args: unknown[]) => claimMock(...args),
checkSendingDomainVerification: (...args: unknown[]) => verifyMock(...args),
removeSendingDomain: (...args: unknown[]) => removeMock(...args),
getSendingDomain: (...args: unknown[]) => getMock(...args),
updateSendingDomainSettings: (...args: unknown[]) => updateMock(...args),
applySendingDomainStatusFromWebhook: (...args: unknown[]) => webhookApplyMock(...args),
}))
const hasCapabilityMock = vi.fn()
vi.mock('@/lib/entitlements/has-capability', async () => {
const actual = await vi.importActual<typeof import('@/lib/entitlements/has-capability')>(
'@/lib/entitlements/has-capability',
)
return {
...actual,
hasCapability: (...args: unknown[]) => hasCapabilityMock(...args),
requireCapability: async (supabase: unknown, companyId: string, key: string) =>
(await hasCapabilityMock(supabase, companyId, key)) ? null : actual.capabilityBlockedResponse(key as never),
}
})
const isSandboxMock = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => isSandboxMock(...args),
}))
// The delivery webhook path is covered by delivery-webhook.test.ts; here we
// only need the domain.updated branch, so the signature check is stubbed.
const verifyWebhookMock = vi.fn()
vi.mock('@/extensions/general/email/lib/delivery-webhook', async () => {
const actual = await vi.importActual<typeof import('@/extensions/general/email/lib/delivery-webhook')>(
'@/extensions/general/email/lib/delivery-webhook',
)
return {
...actual,
isDeliveryWebhookConfigured: () => true,
verifyDeliveryWebhook: (...args: unknown[]) => verifyWebhookMock(...args),
}
})
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ rpc: vi.fn().mockResolvedValue({ data: null, error: null }) }),
}))
function findRoute(method: string, path: string) {
return emailExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'email',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
...overrides,
} as ExtensionContext
}
const ROW = {
id: 'row-1',
company_id: 'company-1',
domain: 'hansbolag.example',
status: 'pending',
sender_local_part: 'faktura',
sender_name: null,
enabled: true,
resend_domain_id: 'rd_1',
dns_records: [],
verified_at: null,
last_checked_at: null,
}
/** A context whose supabase answers the admin-role lookup with `role`. */
function adminCtx(role: 'owner' | 'admin' | 'member' = 'owner') {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role } })
return buildCtx(supabase)
}
beforeEach(() => {
vi.clearAllMocks()
hasCapabilityMock.mockResolvedValue(true)
isSandboxMock.mockResolvedValue(false)
})
describe('GET /sending-domain', () => {
const route = findRoute('GET', '/sending-domain')
it('returns 401 without context', async () => {
const res = await route.handler(createMockRequest('/sending-domain'), undefined)
expect(res.status).toBe(401)
})
it('returns 403 capability_blocked without the opt-in grant (the UI hides on this)', async () => {
hasCapabilityMock.mockResolvedValue(false)
const { supabase } = createQueuedMockSupabase()
const res = await route.handler(createMockRequest('/sending-domain'), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ capability_blocked: boolean; capability: string }>(res)
expect(status).toBe(403)
expect(body.capability_blocked).toBe(true)
expect(body.capability).toBe('custom_sender_domain')
expect(getMock).not.toHaveBeenCalled()
})
it('returns the current row (or null) for any member with the grant', async () => {
getMock.mockResolvedValue(ROW)
const { supabase } = createQueuedMockSupabase()
const res = await route.handler(createMockRequest('/sending-domain'), buildCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res)
expect(status).toBe(200)
expect(body.data.domain).toBe('hansbolag.example')
expect(getMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
})
})
describe('POST /sending-domain', () => {
const route = findRoute('POST', '/sending-domain')
const request = () =>
createMockRequest('/sending-domain', { method: 'POST', body: { domain: 'hansbolag.example' } })
it('returns 403 for a plain member', async () => {
const res = await route.handler(request(), adminCtx('member'))
expect(res.status).toBe(403)
expect(claimMock).not.toHaveBeenCalled()
})
it('returns 403 for a sandbox company', async () => {
isSandboxMock.mockResolvedValue(true)
const res = await route.handler(request(), adminCtx())
expect(res.status).toBe(403)
expect(claimMock).not.toHaveBeenCalled()
})
it('returns 400 on an invalid body', async () => {
const res = await route.handler(
createMockRequest('/sending-domain', { method: 'POST', body: { domain: '' } }),
adminCtx(),
)
expect(res.status).toBe(400)
})
it('claims the domain for an admin', async () => {
claimMock.mockResolvedValue({ ok: true, data: ROW })
const res = await route.handler(request(), adminCtx('admin'))
const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res)
expect(status).toBe(200)
expect(body.data.id).toBe('row-1')
// (tenant RLS client, service-role writer, company, domain)
expect(claimMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'company-1', 'hansbolag.example')
})
it('propagates the helper status code', async () => {
claimMock.mockResolvedValue({ ok: false, status: 409, error: 'Domänen är redan registrerad.' })
const res = await route.handler(request(), adminCtx())
expect(res.status).toBe(409)
})
})
describe('POST /sending-domain/verify', () => {
const route = findRoute('POST', '/sending-domain/verify')
it('returns 404 when no domain exists', async () => {
verifyMock.mockResolvedValue({ ok: false, status: 404, error: 'Ingen avsändardomän är registrerad.' })
const res = await route.handler(createMockRequest('/sending-domain/verify', { method: 'POST' }), adminCtx())
expect(res.status).toBe(404)
})
it('returns the re-checked row', async () => {
verifyMock.mockResolvedValue({ ok: true, data: { ...ROW, status: 'verified' } })
const res = await route.handler(createMockRequest('/sending-domain/verify', { method: 'POST' }), adminCtx())
const { status, body } = await parseJsonResponse<{ data: typeof ROW }>(res)
expect(status).toBe(200)
expect(body.data.status).toBe('verified')
})
})
describe('PATCH /sending-domain', () => {
const route = findRoute('PATCH', '/sending-domain')
it('rejects unknown keys with 400', async () => {
const res = await route.handler(
createMockRequest('/sending-domain', { method: 'PATCH', body: { domain: 'x.example' } }),
adminCtx(),
)
expect(res.status).toBe(400)
expect(updateMock).not.toHaveBeenCalled()
})
it('passes the validated patch through', async () => {
updateMock.mockResolvedValue({ ok: true, data: { ...ROW, enabled: false } })
const res = await route.handler(
createMockRequest('/sending-domain', { method: 'PATCH', body: { enabled: false, sender_name: null } }),
adminCtx(),
)
expect(res.status).toBe(200)
expect(updateMock).toHaveBeenCalledWith(expect.anything(), 'company-1', { enabled: false, sender_name: null })
})
})
describe('DELETE /sending-domain', () => {
const route = findRoute('DELETE', '/sending-domain')
it('returns 403 for a plain member', async () => {
const res = await route.handler(createMockRequest('/sending-domain', { method: 'DELETE' }), adminCtx('member'))
expect(res.status).toBe(403)
expect(removeMock).not.toHaveBeenCalled()
})
it('removes for an owner', async () => {
removeMock.mockResolvedValue({ ok: true, data: { removed: true } })
const res = await route.handler(createMockRequest('/sending-domain', { method: 'DELETE' }), adminCtx())
const { status, body } = await parseJsonResponse<{ data: { removed: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.removed).toBe(true)
})
})
describe('POST /delivery-status: domain.updated', () => {
const route = findRoute('POST', '/delivery-status')
it('applies domain.updated to sending-domain rows and reports the match', async () => {
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
verifyWebhookMock.mockReturnValue({
type: 'domain.updated',
created_at: '2026-08-22T10:00:00Z',
data: { id: 'rd_1', name: 'hansbolag.example', status: 'verified', records: [] },
})
webhookApplyMock.mockResolvedValue('applied')
const res = await route.handler(
createMockRequest('/delivery-status', { method: 'POST', body: { type: 'domain.updated' } }),
undefined,
)
const { status, body } = await parseJsonResponse<{ data: { applied: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.applied).toBe(true)
expect(webhookApplyMock).toHaveBeenCalledWith(expect.anything(), { id: 'rd_1', status: 'verified', records: [] })
})
it('acknowledges an unknown domain with 200 but answers 500 on a database error so Svix retries', async () => {
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
verifyWebhookMock.mockReturnValue({
type: 'domain.updated',
created_at: '2026-08-22T10:00:00Z',
data: { id: 'rd_other', name: 'other.example', status: 'verified', records: [] },
})
const request = () =>
createMockRequest('/delivery-status', { method: 'POST', body: { type: 'domain.updated' } })
webhookApplyMock.mockResolvedValue('no_match')
const ignored = await parseJsonResponse<{ data: { applied: boolean; reason: string } }>(
await route.handler(request(), undefined),
)
expect(ignored.status).toBe(200)
expect(ignored.body.data).toEqual({ applied: false, reason: 'no_matching_domain' })
webhookApplyMock.mockResolvedValue('error')
const failed = await route.handler(request(), undefined)
expect(failed.status).toBe(500)
})
})