/** * Who is allowed to touch a payment. Cheap to run (no booking chain), and the * failures here are the expensive kind: a tenant reading another tenant's * invoice, or an unauthenticated caller marking one paid. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import request from "supertest"; import { PAYMENT_API, api, closeDb, customerA, customerB, db, freightServer, login, payment, } from "./client"; /** Company A, from e2e/freight/cypress/fixtures/seed-company.sql. */ const TENANT_A_TIN = "0102030405"; /** * This file used to read "the newest invoice of company A" and `return` early * when there wasn't one — which passed silently on a database no payment spec * had run against yet. Now that every shard starts from a pristine clone, that * would be *every* run. It bills itself instead. * * Straight SQL, not the booking chain: the point of this file is that it is * cheap, and a cross-tenant read is refused on ownership alone — nothing here * cares how the invoice came to exist. */ async function seedTenantAInvoice(): Promise { const rows = await db<{ id: string }>( `INSERT INTO freight.invoices ( id, invoice_number, company_id, company_profile_id, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, currency, status, source, source_id, type, issued_at, due_at, payments ) SELECT gen_random_uuid(), $1, c.id, p.id, 1000, 0, 1000, 0, 1000, 'ETB', 'ISSUED', 'booking', gen_random_uuid()::text, 'PREPAID', now(), now() + interval '7 days', '[]'::jsonb FROM freight.companies c JOIN freight.company_profiles p ON p.company_id = c.id AND p.deleted_at IS NULL WHERE c.tin = $2 AND c.deleted_at IS NULL LIMIT 1 RETURNING id`, [`INV-AUTHZ-${Date.now()}`, TENANT_A_TIN], ); const id = rows[0]?.id; if (!id) { throw new Error( `authz: could not bill company ${TENANT_A_TIN} — seed-company.sql missing from this shard?`, ); } return id; } describe("payment authorization boundaries", () => { let invoiceId: string; beforeAll(async () => { invoiceId = await seedTenantAInvoice(); }); afterAll(closeDb); it("hides one tenant's invoice from the other", async () => { const res = await api(customerB, "get", `/api/billing/my-invoices/${invoiceId}`); expect([403, 404]).toContain(res.status); }); it("refuses to let one tenant pay the other's invoice", async () => { const res = await api(customerB, "post", `/api/billing/my-invoices/${invoiceId}/pay`, { method: "CBE_BIRR", platform: "web", }); expect(res.status).toBeGreaterThanOrEqual(400); }); it("keeps a portal customer out of backoffice payment operations", async () => { const res = await api(customerA, "get", "/api/billing/invoices"); expect(res.status).toBeGreaterThanOrEqual(400); }); it("rejects a portal account on the backoffice login audience", async () => { const res = await login(customerA, "12345678", "backoffice"); expect(res.status).toBeGreaterThanOrEqual(400); }); it("requires the service token on freight's mark-paid callback", async () => { const body = { version: 1, eventId: "authz-probe", eventType: "payment.succeeded", occurredAt: new Date().toISOString(), service: "FREIGHT", intentId: "00000000-0000-0000-0000-000000000000", referenceType: "SHIPMENT", referenceId: "00000000-0000-0000-0000-000000000000", provider: "CBE_BIRR", amountMinor: 1, currency: "ETB", }; const res = await request(await freightServer()) .post("/api/internal/payments/mark-paid") .send(body); expect([401, 403]).toContain(res.status); }); it("requires the service token on the payment API's internal surface", async () => { const res = await request(PAYMENT_API).get("/payments/intents?service=FREIGHT"); expect([400, 401, 403]).toContain(res.status); // …and accepts it when present (400 = bad query, not an auth failure). const withToken = await payment("get", "/payments/intents?service=FREIGHT"); expect([401, 403]).not.toContain(withToken.status); }); it("leaves the provider webhook surface public — trust is the signature", async () => { // A garbage payload must be acked, not 401'd: providers do not authenticate. const res = await request(PAYMENT_API).post("/webhooks/cbe-birr").send({ nonsense: true }); expect(res.status).toBe(200); }); });