fix(entitlements): close paywall leaks on interactive bank, SKV, and email routes (#910)

* fix(entitlements): close paywall leaks on interactive bank, SKV, and email routes

The capability gate covered crons, MCP tools, and invoice send, but four
interactive server paths still bypassed it ahead of the 2026-07-07 trial
cutover:

- enable-banking POST /connect and /sync (bank_sync)
- skatteverket authorize/validate/draft/lock/submit/spara/las/kontrollera/
  skattekonto-sync (skatteverket); unlock and all reads stay free, and the
  AGI/VAT file download path remains ungated per the manual-filing decision
- salary payslip email send (email_send)
- recurring-invoice auto-send (email_send); the invoice is still created,
  only the email is withheld (freeze-and-retain)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(entitlements): pin unlock routes as paywall-free recovery paths

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-06 22:20:26 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 573feea890
commit 7f24ede6c0
9 changed files with 191 additions and 0 deletions
@@ -18,6 +18,9 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/email/service', () => ({ getEmailService: vi.fn() }))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appUrl: 'https://app.example.test' }),
}))
@@ -80,6 +83,20 @@ describe('POST /api/salary/runs/[id]/payslips/send', () => {
expect(response.status).toBe(401)
})
it('returns 403 when the company lacks the email_send capability', async () => {
const { requireCapability } = await import('@/lib/entitlements/has-capability')
vi.mocked(requireCapability).mockResolvedValueOnce(
NextResponse.json({ capability_blocked: true }, { status: 403 }),
)
const { supabase } = createQueuedMockSupabase()
authed(supabase)
mockEmail({ success: true })
const request = createMockRequest('/api/salary/runs/run-1/payslips/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
expect(response.status).toBe(403)
})
it('returns 404 when the run does not exist', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
@@ -6,6 +6,8 @@ import { getEmailService } from '@/lib/email/service'
import { getBranding } from '@/lib/branding/service'
import { rotateLinkForEmployee } from '@/lib/salary/payslips/links'
import { buildPayslipLinkEmail } from '@/lib/salary/payslips/email-template'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
ensureInitialized()
@@ -22,6 +24,10 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary_run.payslips_send',
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
const blocked = await requireCapability(supabase, companyId, CAPABILITY.email_send)
if (blocked) return blocked
const emailService = getEmailService()
const { data: run } = await supabase