* 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>
279 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|