mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
The suite drove a containerized freight API, so every code change needed an image rebuild before a test could see it, and there was no way to attach a debugger. Files also ran strictly in sequence against one shared database, which is the root of the warm-stack gotchas the README documents: stowaway paid bookings climbing back aboard, a short consist on the fifth file. The freight app now boots inside each vitest worker from dist/, and each worker owns a whole shard of the topology - its own database, payment API, gateway mock and broker vhost - so nothing mutable is shared and files run in parallel. Full suite drops from roughly 20 minutes to 196s at 4 shards. - main.ts exports createFreightApp() so the harness applies the same prefix, pipes, filters and interceptors as production instead of replaying them by hand; self-start is guarded by require.main so the Dockerfile CMD still boots - booking-window tick cadence is env-driven (BOOKING_WINDOW_TICK_CRON), */1 in the suite, */10 unchanged in production - prepare-shards.mjs seeds a template database (boot seeders, then the SQL fixtures that depend on them) and clones it per shard; it.mjs re-clones on every run, so each run is hermetic - gateway mock and payment API are generated per shard: the mock keeps modes and orders process-global and 20 of 25 specs reset it in beforeAll, and the inbound CBE bill query has to reach one specific shard's app - poll() samples every 250ms instead of 2000ms, keeping the caller's deadline - authz.it.ts seeds its own invoice; it previously read another spec's leftover and returned early, which silently passed on a pristine database Known: an unlocked MAX(sequence_no)+1 in train-scheduling.service.ts races under concurrent allocation and leaves a short consist, so 1-3 specs fail intermittently. Pre-existing and reproduces at the production tick cadence.
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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);
|
|
});
|
|
});
|