feat(payments): verified tenant-owned 0pt orders #69
@@ -1,13 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
|
||||
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||
import { getOwnedOrder, setOwnedStatusIfCurrent } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
|
||||
// GET /api/orders/{id}
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const auth = await requireRouteAuth(req);
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
const { id } = await ctx.params;
|
||||
const order = await getOrder(id);
|
||||
const order = await getOwnedOrder(id, auth.owner.tenantId);
|
||||
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
||||
return NextResponse.json(order);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
||||
// PATCH /api/orders/{id} — status transition (deposit → in_production → review → accepted ...).
|
||||
// Escrow rule (docs/01): balance never charged before 'accepted'. Stripe wiring follows.
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const auth = await requireRouteAuth(req);
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
const { id } = await ctx.params;
|
||||
let body: { status?: string };
|
||||
@@ -35,7 +35,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
|
||||
if (!body.status || !allowed.includes(body.status)) {
|
||||
return NextResponse.json({ error: { code: "bad_status", message: "Ogiltig status" } }, { status: 400 });
|
||||
}
|
||||
const order = await getOrder(id);
|
||||
const order = await getOwnedOrder(id, auth.owner.tenantId);
|
||||
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
||||
const previous: Record<string, string[]> = {
|
||||
quoted: ["draft"],
|
||||
@@ -47,7 +47,7 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
|
||||
if (!previous[body.status]?.includes(order.status)) {
|
||||
return NextResponse.json({ error: { code: "invalid_transition" } }, { status: 409 });
|
||||
}
|
||||
const updated = await setStatusIfCurrent(id, order.status, body.status);
|
||||
const updated = await setOwnedStatusIfCurrent(id, auth.owner.tenantId, order.status, body.status);
|
||||
if (!updated) return NextResponse.json({ error: { code: "stale_transition" } }, { status: 409 });
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createOrder, listOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store";
|
||||
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||
import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
|
||||
// POST /api/orders (docs/04) — create a draft order from a configurator config.
|
||||
// Intentionally anonymous: a prospect drafts/estimates a job before signing in
|
||||
// to pay the deposit (see docs/01, the configurator flow). Only reading back
|
||||
// the order queue (GET, below) is a data-exposure risk.
|
||||
// A payable order is created only for a verified Zitadel tenant/buyer.
|
||||
// Anonymous prospects may obtain estimates but cannot mint payment orders.
|
||||
//
|
||||
// P014: honors an optional `Idempotency-Key` header (the convention this
|
||||
// skill's own docs already name -- "Idempotency-Key on order/payment POSTs")
|
||||
@@ -15,6 +14,8 @@ import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||
// recon "Duplicate delivery idempotent: NOT MET"). Omitting the header keeps
|
||||
// the pre-P014 behavior exactly: every call creates a fresh order.
|
||||
export async function POST(req: NextRequest) {
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
let body: { flow?: string; config?: OrderConfig };
|
||||
try {
|
||||
body = await req.json();
|
||||
@@ -28,7 +29,7 @@ export async function POST(req: NextRequest) {
|
||||
const config = body.config || {};
|
||||
const idempotencyKey = req.headers.get("idempotency-key") ?? undefined;
|
||||
try {
|
||||
const { order, replayed } = await createOrder(flow, config, idempotencyKey);
|
||||
const { order, replayed } = await createOrder(flow, config, idempotencyKey, auth.owner);
|
||||
return NextResponse.json(
|
||||
{ order_id: order.order_id, status: order.status, deposit_ore: order.deposit_ore, total_ore: order.total_ore },
|
||||
{ status: replayed ? 200 : 201 },
|
||||
@@ -47,8 +48,8 @@ export async function POST(req: NextRequest) {
|
||||
// src/auth/require-route-auth.ts for why the request is re-verified rather
|
||||
// than trusting a forwarded header.
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await requireRouteAuth(req);
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
const orders = await listOrders();
|
||||
const orders = await listOwnedOrders(auth.owner.tenantId);
|
||||
return NextResponse.json(orders);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrder } from "@/lib/orders-store";
|
||||
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||
import { getOwnedOrder } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
|
||||
// POST /api/payments/[order_id]/deposit
|
||||
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
|
||||
@@ -8,7 +8,7 @@ export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ order_id: string }> },
|
||||
) {
|
||||
const auth = await requireRouteAuth(request);
|
||||
const auth = await requireOrderOwner(request);
|
||||
if (auth.response) return auth.response;
|
||||
const { order_id } = await params;
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function POST(
|
||||
// request.nextUrl.origin reflects the actual request URL, not attacker-controlled input.
|
||||
const origin = request.nextUrl.origin;
|
||||
|
||||
const order = await getOrder(order_id);
|
||||
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getStripe } from "@/lib/stripe";
|
||||
import { getOrder } from "@/lib/orders-store";
|
||||
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||
import { getOwnedOrder } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
|
||||
// POST /api/payments/{order_id}/release (docs/04) — charge the held 50% balance.
|
||||
// Called on ACCEPT only; escrow balance is never charged before approval.
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: string }> }) {
|
||||
const auth = await requireRouteAuth(req);
|
||||
const auth = await requireOrderOwner(req);
|
||||
if (auth.response) return auth.response;
|
||||
const { order_id } = await ctx.params;
|
||||
const order = await getOrder(order_id);
|
||||
const order = await getOwnedOrder(order_id, auth.owner.tenantId);
|
||||
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
||||
if (order.status !== "accepted") {
|
||||
return NextResponse.json({ error: { code: "not_accepted", message: "Balans frigörs först vid godkänd leverans" } }, { status: 409 });
|
||||
|
||||
@@ -35,6 +35,11 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
const order = await getOrder(oid);
|
||||
if (!order) return NextResponse.json({ error: "order_not_found" }, { status: 503 });
|
||||
if (!order.tenant_id || !order.buyer_subject) {
|
||||
// A legacy anonymous draft is not a payable order. Do not silently
|
||||
// acknowledge a paid event until an audited owner-remediation path exists.
|
||||
return NextResponse.json({ error: "order_owner_missing" }, { status: 503 });
|
||||
}
|
||||
const expectedAmount = kind === "balance" ? order.balance_ore : order.deposit_ore;
|
||||
if (s.amount_total !== expectedAmount || s.currency?.toLowerCase() !== "sek") {
|
||||
return NextResponse.json({ error: "payment_mismatch" }, { status: 422 });
|
||||
|
||||
@@ -50,9 +50,15 @@ without creating a payable order until sign-in.
|
||||
|
||||
## Action items
|
||||
|
||||
1. [ ] Build and verify the migration and deploy runner.
|
||||
2. [ ] Bind create-order to verified tenant and buyer subject; scope idempotency.
|
||||
3. [ ] Scope GET/PATCH/deposit/release to tenant at the SQL boundary.
|
||||
1. [x] Build and smoke-verify `sql/011_orders_tenant_ownership.sql` on
|
||||
throwaway pgvector/Postgres 16, including a second idempotent application.
|
||||
A deploy migration runner and production application remain open.
|
||||
2. [x] Bind create-order to verified tenant and buyer subject; scope idempotency.
|
||||
3. [x] Scope buyer GET/PATCH/deposit/release to tenant at the SQL boundary.
|
||||
4. [ ] Wire UI Zitadel login/session and CLI/MCP bearer auth; keep anonymous estimates.
|
||||
5. [ ] Test cross-tenant 404, legacy unowned rejection, webhook, and staging
|
||||
purchase/refund end-to-end before enabling live Stripe.
|
||||
|
||||
Implementation is pending PR review and merge. It is not evidence that the
|
||||
production database has been migrated or that the browser purchase path is
|
||||
ready: the current UI does not send a verified Zitadel credential.
|
||||
|
||||
@@ -137,12 +137,12 @@ Risknivå = SIAX L0–L5-taxonomin, bedömd av mig utifrån vad handlern gör.
|
||||
| Katalog / källor / fordon | `GET /api/{catalog,sources,vehicles}` | L1 | Nej |
|
||||
| Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions |
|
||||
| AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej |
|
||||
| Skapa order | `POST /api/orders` | L2 | Nej |
|
||||
| **Lista alla ordrar** | `GET /api/orders` | **L3** | Route-auth i source; org-scoping saknas |
|
||||
| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Route-auth i source; orderägande saknas |
|
||||
| Skapa order | `POST /api/orders` | L2 | Verifierad buyer-principal i source; UI-SSO återstår |
|
||||
| **Lista buyer-ordrar** | `GET /api/orders` | **L3** | Verifierad tenant och tenant-scoped SQL i source |
|
||||
| Läs/ändra buyer-order | `GET,PATCH /api/orders/{id}` | L3 | Verifierad tenant och tenant-scoped SQL i source |
|
||||
| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | Route-auth i source; äldre liveobservation ej omverifierad |
|
||||
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Route-auth i source; orderägande saknas |
|
||||
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Route-auth i source; `delivered` först efter betald webhook; orderägande saknas |
|
||||
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Verifierad tenant + ägd order i source; live-UI återstår |
|
||||
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Ägd order; `delivered` först efter betald webhook; live-UI återstår |
|
||||
| Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` |
|
||||
| Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej |
|
||||
| Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) |
|
||||
@@ -353,12 +353,15 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke
|
||||
betalning som lyckad. Klient-PATCH kräver nu verifierad auth, följer
|
||||
tillåtna statusövergångar och kan aldrig skriva `in_production`/`delivered`;
|
||||
concurrent writes använder jämför-och-sätt. Slutbetalningsrouten kräver
|
||||
också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap
|
||||
och tenant-bindning vid checkout/acceptance behöver implementeras och
|
||||
verifieras; en verifierad identitet ensam är inte per-order auktorisation.
|
||||
Enskild order-GET och deposit-checkout kräver nu också route-auth; dessa
|
||||
två vägar är inte längre anonyma i source. Ägarbindningens beslut och
|
||||
beroenden dokumenteras i `ADR-001-order-ownership.md`.
|
||||
också verifierad auth. Ny order-POST kräver nu en verifierad
|
||||
Zitadel-principal; dennes tenant och subject lagras i
|
||||
`orders.tenant_id`/`buyer_subject` via `sql/011`. Buyer GET/list/PATCH
|
||||
och deposit/release använder tenant-predikat i SQL; legacy-ordrar utan
|
||||
owner är osynliga och obetalbara. **Fortfarande öppet före live-läge:**
|
||||
migrationen har bara smoke-testats på engångs-Postgres och måste appliceras
|
||||
i målmiljön före deploy. Browser-UI saknar produktions-SSO/Bearer och
|
||||
stagingköp/refund är ännu inte verifierat. Beslut och nästa steg finns i
|
||||
`ADR-001-order-ownership.md`.
|
||||
- `SECURITY.md` finns med rapporteringsväg (`security@siax.io`).
|
||||
|
||||
### KÄNDA LUCKOR
|
||||
@@ -395,15 +398,16 @@ Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild
|
||||
order-GET, deposit- och release-checkout samt order-PATCH har nu explicita
|
||||
route-auth-grindar i source. De äldre live-observationerna av anonymt 200
|
||||
beskriver tidigare deploy, inte verifierat nuläge. Övriga icke-prefixade
|
||||
API:er kräver egen inventering, och per-order tenantägande är fortfarande
|
||||
öppet enligt ADR-001.
|
||||
API:er kräver egen inventering. Buyer-order-API:t har nu tenant-predikat i
|
||||
source, men migration, UI-SSO och liveverifiering återstår enligt ADR-001.
|
||||
|
||||
**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.**
|
||||
**S4 — Historisk escrow-bugg åtgärdad i source; liveköp återstår.**
|
||||
`POST /api/payments/{order_id}/release` kräver nu route-auth och skapar
|
||||
Checkout först efter `accepted`. En skapad session sätter inte `delivered`;
|
||||
endast en signerad, betald och matchande Stripe-webhook får göra det.
|
||||
Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning
|
||||
får inte aktiveras innan ADR-001 är genomförd och verifierad.
|
||||
Cross-tenant-skydd av buyer-ordern finns nu i SQL-predikat, men är inte
|
||||
produktionsbevisat innan `sql/011` har applicerats, UI-SSO är kopplat och
|
||||
stagingköp/refund är verifierat. Livebetalning får därför inte aktiveras.
|
||||
|
||||
**S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.**
|
||||
`src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 0pt order-ownership cutover
|
||||
|
||||
Status: source implementation in PR #66; **not applied to production**.
|
||||
Canonical design: [ADR-001](./ADR-001-order-ownership.md).
|
||||
|
||||
## Dependency order
|
||||
|
||||
1. Confirm the target Postgres is the canonical 0pt order store. Capture a
|
||||
recoverable database snapshot and identify current migration state.
|
||||
`sql/001_init.sql` and `sql/006_orders_idempotency.sql` must already be
|
||||
present; this repository has no general automated migration runner yet.
|
||||
2. Apply `sql/011_orders_tenant_ownership.sql` transactionally to staging,
|
||||
then production only after staging verification. The file is idempotent
|
||||
and leaves legacy NULL-owned rows untouched. For an environment where
|
||||
`DATABASE_URL` is securely injected, a manual controlled step is:
|
||||
|
||||
```bash
|
||||
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction -f sql/011_orders_tenant_ownership.sql
|
||||
```
|
||||
|
||||
3. Verify `orders.tenant_id`, `orders.buyer_subject`, the ownership-pair
|
||||
check constraint and `orders_tenant_created_idx`. Record migration
|
||||
output and deployment SHA in the rollout evidence. Do not deploy the
|
||||
tenant-scoped source before the schema is verified; an undefined column
|
||||
will fail requests.
|
||||
4. Wire a real Zitadel customer UI session and send the verified credential
|
||||
to every order/payment API call. Existing dev HMAC login is not a payment
|
||||
identity. Keep anonymous composition as an estimate only.
|
||||
5. In staging, prove: no token -> 401; spoofed tenant header -> 401/403;
|
||||
another tenant's order -> 404; legacy unowned order -> 404/unpayable;
|
||||
matching signed paid Stripe event -> correct status; unpaid/mismatched
|
||||
event -> no status change; repeat delivery -> idempotent. Test purchase,
|
||||
cancellation and refund with real sandbox endpoints. Only then review
|
||||
SIAX Technology AB live credentials and enable live Stripe.
|
||||
|
||||
## Rollback and legacy handling
|
||||
|
||||
Do not drop ownership columns to roll back the application. Roll back the
|
||||
application deployment to its prior SHA if needed, retaining the additive
|
||||
columns and snapshot. A paid Stripe event for a legacy unowned order receives
|
||||
503 and requires an explicit, audited support decision; the webhook does not
|
||||
guess a tenant or acknowledge it as fulfilled. Do not silently backfill
|
||||
ownership from order config, browser storage, or a known order ID.
|
||||
+80
-16
@@ -16,6 +16,9 @@ export interface OrderConfig {
|
||||
|
||||
export interface Order {
|
||||
order_id: string;
|
||||
/** NULL for legacy anonymous drafts; never inferred from request config. */
|
||||
tenant_id?: string | null;
|
||||
buyer_subject?: string | null;
|
||||
flow: string;
|
||||
status: string;
|
||||
config: OrderConfig;
|
||||
@@ -27,6 +30,16 @@ export interface Order {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OrderOwner {
|
||||
tenantId: string;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Collision-free namespace within the existing global idempotency index. */
|
||||
function ownedIdempotencyKey(owner: OrderOwner, key: string): string {
|
||||
return `owned:${owner.tenantId.length}:${owner.tenantId}:${key}`;
|
||||
}
|
||||
|
||||
// Non-durable fallback used only until a database is provisioned.
|
||||
const mem = new Map<string, Order>();
|
||||
// idempotency_key -> order_id, mirroring the Postgres unique index added by
|
||||
@@ -53,15 +66,16 @@ function isUniqueViolation(err: unknown): boolean {
|
||||
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
|
||||
}
|
||||
|
||||
function sameSubmission(a: Order, flow: string, config: OrderConfig): boolean {
|
||||
return a.flow === flow && JSON.stringify(a.config) === JSON.stringify(config);
|
||||
function sameSubmission(a: Order, flow: string, config: OrderConfig, owner?: OrderOwner): boolean {
|
||||
return a.flow === flow && JSON.stringify(a.config) === JSON.stringify(config)
|
||||
&& (!owner || (a.tenant_id === owner.tenantId && a.buyer_subject === owner.subject));
|
||||
}
|
||||
|
||||
export async function findOrderByIdempotencyKey(key: string): Promise<Order | null> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
FROM orders WHERE idempotency_key = ${key} LIMIT 1`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
@@ -118,12 +132,14 @@ export interface CreateOrderResult {
|
||||
* unique-violation on INSERT is caught and re-resolved the same way a
|
||||
* pre-existing row would be, rather than surfacing a raw DB error.
|
||||
*/
|
||||
export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string): Promise<CreateOrderResult> {
|
||||
const key = idempotencyKey?.trim() || undefined;
|
||||
export async function createOrder(flow: string, config: OrderConfig, idempotencyKey?: string, owner?: OrderOwner): Promise<CreateOrderResult> {
|
||||
if (owner && (!owner.tenantId.trim() || !owner.subject.trim())) throw new Error("Verified order owner is required");
|
||||
const rawKey = idempotencyKey?.trim() || undefined;
|
||||
const key = rawKey && owner ? ownedIdempotencyKey(owner, rawKey) : rawKey;
|
||||
if (key) {
|
||||
const existing = await findOrderByIdempotencyKey(key);
|
||||
if (existing) {
|
||||
if (sameSubmission(existing, flow, config)) return { order: existing, replayed: true };
|
||||
if (sameSubmission(existing, flow, config, owner)) return { order: existing, replayed: true };
|
||||
throw new IdempotencyKeyConflictError(key);
|
||||
}
|
||||
}
|
||||
@@ -133,13 +149,15 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency
|
||||
if (sql) {
|
||||
try {
|
||||
const [row] = await sql<{ order_id: string; created_at: string }[]>`
|
||||
INSERT INTO orders (flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, idempotency_key)
|
||||
VALUES (${flow}, 'draft', ${sql.json(config as Parameters<typeof sql.json>[0])}, ${est.price * 100}, ${est.deposit * 100},
|
||||
${est.remainder * 100}, ${est.timelineDays}, ${est.estConfidence / 100}, ${key ?? null})
|
||||
INSERT INTO orders (tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, idempotency_key)
|
||||
VALUES (${owner?.tenantId ?? null}, ${owner?.subject ?? null}, ${flow}, 'draft', ${sql.json(config as Parameters<typeof sql.json>[0])},
|
||||
${est.price * 100}, ${est.deposit * 100}, ${est.remainder * 100},
|
||||
${est.timelineDays}, ${est.estConfidence / 100}, ${key ?? null})
|
||||
RETURNING order_id, created_at`;
|
||||
return {
|
||||
order: {
|
||||
order_id: row.order_id, flow, status: "draft", config,
|
||||
order_id: row.order_id, tenant_id: owner?.tenantId ?? null, buyer_subject: owner?.subject ?? null,
|
||||
flow, status: "draft", config,
|
||||
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
|
||||
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: row.created_at,
|
||||
},
|
||||
@@ -149,7 +167,7 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency
|
||||
if (key && isUniqueViolation(err)) {
|
||||
const raced = await findOrderByIdempotencyKey(key);
|
||||
if (raced) {
|
||||
if (sameSubmission(raced, flow, config)) return { order: raced, replayed: true };
|
||||
if (sameSubmission(raced, flow, config, owner)) return { order: raced, replayed: true };
|
||||
throw new IdempotencyKeyConflictError(key);
|
||||
}
|
||||
}
|
||||
@@ -159,7 +177,8 @@ export async function createOrder(flow: string, config: OrderConfig, idempotency
|
||||
|
||||
const id = (globalThis.crypto?.randomUUID?.() ?? `SX-${Math.floor(1000 + Math.random() * 9000)}`);
|
||||
const order: Order = {
|
||||
order_id: id, flow, status: "draft", config,
|
||||
order_id: id, tenant_id: owner?.tenantId ?? null, buyer_subject: owner?.subject ?? null,
|
||||
flow, status: "draft", config,
|
||||
total_ore: est.price * 100, deposit_ore: est.deposit * 100, balance_ore: est.remainder * 100,
|
||||
est_days: est.timelineDays, est_confidence: est.estConfidence / 100, created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -172,18 +191,43 @@ export async function getOrder(id: string): Promise<Order | null> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
FROM orders WHERE order_id::text = ${id} LIMIT 1`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
return mem.get(id) ?? null;
|
||||
}
|
||||
|
||||
/** Buyer read is scoped in SQL itself; legacy NULL-owned rows are invisible. */
|
||||
export async function getOwnedOrder(id: string, tenantId: string): Promise<Order | null> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
FROM orders WHERE order_id::text = ${id} AND tenant_id = ${tenantId} LIMIT 1`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
const order = mem.get(id);
|
||||
return order?.tenant_id === tenantId ? order : null;
|
||||
}
|
||||
|
||||
export async function listOwnedOrders(tenantId: string): Promise<Order[]> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
FROM orders WHERE tenant_id = ${tenantId} ORDER BY created_at DESC LIMIT 100`;
|
||||
return rows.map(coerceOrder);
|
||||
}
|
||||
return [...mem.values()].filter((o) => o.tenant_id === tenantId)
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
}
|
||||
|
||||
export async function listOrders(): Promise<Order[]> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
SELECT order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
SELECT order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at
|
||||
FROM orders ORDER BY created_at DESC LIMIT 100`;
|
||||
return rows.map(coerceOrder);
|
||||
}
|
||||
@@ -195,7 +239,7 @@ export async function setStatus(id: string, status: string): Promise<Order | nul
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
UPDATE orders SET status = ${status}, updated_at = now() WHERE order_id::text = ${id}
|
||||
RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
|
||||
RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
const o = mem.get(id);
|
||||
@@ -210,7 +254,7 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri
|
||||
const rows = await sql<Order[]>`
|
||||
UPDATE orders SET status = ${next}, updated_at = now()
|
||||
WHERE order_id::text = ${id} AND status = ${current}
|
||||
RETURNING order_id, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
|
||||
RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore, balance_ore, est_days, est_confidence, created_at`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
const order = mem.get(id);
|
||||
@@ -219,3 +263,23 @@ export async function setStatusIfCurrent(id: string, current: string, next: stri
|
||||
mem.set(id, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** Buyer status change is both atomic and tenant-scoped at the SQL boundary. */
|
||||
export async function setOwnedStatusIfCurrent(
|
||||
id: string, tenantId: string, current: string, next: string,
|
||||
): Promise<Order | null> {
|
||||
const sql = getSqlForDurableStore("0pt.orders");
|
||||
if (sql) {
|
||||
const rows = await sql<Order[]>`
|
||||
UPDATE orders SET status = ${next}, updated_at = now()
|
||||
WHERE order_id::text = ${id} AND tenant_id = ${tenantId} AND status = ${current}
|
||||
RETURNING order_id, tenant_id, buyer_subject, flow, status, config, total_ore, deposit_ore,
|
||||
balance_ore, est_days, est_confidence, created_at`;
|
||||
return rows[0] ? coerceOrder(rows[0]) : null;
|
||||
}
|
||||
const order = mem.get(id);
|
||||
if (!order || order.tenant_id !== tenantId || order.status !== current) return null;
|
||||
order.status = next;
|
||||
mem.set(id, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 0pt payment-order ownership, ADR-001.
|
||||
-- Apply after 001_init.sql and before deploying tenant-scoped order routes.
|
||||
-- Legacy orders remain NULL-owned and cannot be read or paid by a buyer API.
|
||||
-- Do not backfill from client-supplied config or guessed order IDs.
|
||||
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id text;
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS buyer_subject text;
|
||||
|
||||
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_ownership_pair_ck;
|
||||
ALTER TABLE orders ADD CONSTRAINT orders_ownership_pair_ck
|
||||
CHECK ((tenant_id IS NULL AND buyer_subject IS NULL)
|
||||
OR (tenant_id IS NOT NULL AND length(tenant_id) > 0
|
||||
AND buyer_subject IS NOT NULL AND length(buyer_subject) > 0));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS orders_tenant_created_idx
|
||||
ON orders (tenant_id, created_at DESC)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { requireRouteAuth } from "./require-route-auth";
|
||||
import type { OrderOwner } from "@/lib/orders-store";
|
||||
|
||||
export type OrderOwnerAuthResult =
|
||||
| { response: NextResponse; owner?: never }
|
||||
| { response?: never; owner: OrderOwner };
|
||||
|
||||
/** Payment/order routes require a real verified principal even in dev escape mode. */
|
||||
export async function requireOrderOwner(req: NextRequest): Promise<OrderOwnerAuthResult> {
|
||||
const auth = await requireRouteAuth(req);
|
||||
if (auth.response) return { response: auth.response };
|
||||
const principal = auth.ctx.principal;
|
||||
if (!principal?.tenantId || !principal.sub) {
|
||||
return {
|
||||
response: NextResponse.json({ error: "OPT_UNAUTHENTICATED", message: "Verified buyer required" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
return { owner: { tenantId: principal.tenantId, subject: principal.sub } };
|
||||
}
|
||||
@@ -90,10 +90,10 @@ describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('AUTH_ENFORCE=false (local-dev escape hatch) still allows both routes through, unauthenticated', async () => {
|
||||
it('AUTH_ENFORCE=false cannot mint/read payable orders without a verified buyer', async () => {
|
||||
process.env.AUTH_ENFORCE = 'false';
|
||||
const ordersRes = await ordersGET(req('/api/orders'));
|
||||
expect(ordersRes.status).toBe(200);
|
||||
expect(ordersRes.status).toBe(401);
|
||||
const metricsRes = await adminMetricsGET(req('/api/admin/metrics'));
|
||||
expect(metricsRes.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -6,16 +6,16 @@ const mocks = vi.hoisted(() => ({
|
||||
getOrder: vi.fn(),
|
||||
setStatusIfCurrent: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.auth }));
|
||||
vi.mock("@/src/auth/require-order-owner", () => ({ requireOrderOwner: mocks.auth }));
|
||||
vi.mock("@/lib/orders-store", () => ({
|
||||
getOrder: mocks.getOrder,
|
||||
setStatusIfCurrent: mocks.setStatusIfCurrent,
|
||||
getOwnedOrder: mocks.getOrder,
|
||||
setOwnedStatusIfCurrent: mocks.setStatusIfCurrent,
|
||||
}));
|
||||
|
||||
import { PATCH } from "@/app/api/orders/[id]/route";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.auth.mockReset().mockResolvedValue({ ctx: { tenantId: "verified-tenant" } });
|
||||
mocks.auth.mockReset().mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } });
|
||||
mocks.getOrder.mockReset().mockResolvedValue({ order_id: "order-1", status: "draft" });
|
||||
mocks.setStatusIfCurrent.mockReset().mockResolvedValue({ order_id: "order-1", status: "accepted" });
|
||||
});
|
||||
@@ -50,5 +50,6 @@ it("cannot jump from draft directly to accepted", async () => {
|
||||
it("uses an atomic compare-and-set transition after review", async () => {
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "review" });
|
||||
expect((await PATCH(request("accepted"), ctx)).status).toBe(200);
|
||||
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "review", "accepted");
|
||||
expect(mocks.getOrder).toHaveBeenCalledWith("order-1", "verified-tenant");
|
||||
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "verified-tenant", "review", "accepted");
|
||||
});
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* used elsewhere in this suite (tests/api/anonymous-routes-auth.test.ts) of
|
||||
* calling the route handler directly with a hand-built NextRequest.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
vi.mock("@/src/auth/require-order-owner", () => ({
|
||||
requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }),
|
||||
}));
|
||||
import { POST as ordersPOST } from "@/app/api/orders/route";
|
||||
|
||||
function postReq(body: unknown, headers: Record<string, string> = {}): NextRequest {
|
||||
|
||||
@@ -6,22 +6,24 @@ vi.mock("@/lib/stripe", () => ({
|
||||
getStripe: () => ({ checkout: { sessions: { create: mocks.create } } }),
|
||||
}));
|
||||
vi.mock("@/lib/orders-store", () => ({
|
||||
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }),
|
||||
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", status: "accepted", balance_ore: 5000 }),
|
||||
setStatus: mocks.setStatus,
|
||||
}));
|
||||
vi.mock("@/src/auth/require-route-auth", () => ({
|
||||
requireRouteAuth: mocks.auth,
|
||||
vi.mock("@/src/auth/require-order-owner", () => ({
|
||||
requireOrderOwner: mocks.auth,
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/payments/[order_id]/release/route";
|
||||
import { getOwnedOrder } from "@/lib/orders-store";
|
||||
|
||||
it("does not mark delivery complete merely by creating a balance Checkout Session", async () => {
|
||||
mocks.auth.mockResolvedValue({ ctx: { tenantId: "verified-tenant" } });
|
||||
mocks.auth.mockResolvedValue({ owner: { tenantId: "verified-tenant", subject: "buyer-1" } });
|
||||
mocks.create.mockResolvedValue({ url: "https://checkout.stripe.com/test" });
|
||||
const req = new NextRequest("https://0pt.app/api/payments/order-1/release", { method: "POST" });
|
||||
|
||||
const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "verified-tenant");
|
||||
expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: { order_id: "order-1", kind: "balance" },
|
||||
}));
|
||||
|
||||
@@ -2,13 +2,14 @@ import { afterEach, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/orders-store", () => ({
|
||||
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
|
||||
getOwnedOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
|
||||
}));
|
||||
vi.mock("@/src/auth/require-route-auth", () => ({
|
||||
requireRouteAuth: vi.fn().mockResolvedValue({ ctx: { tenantId: "tenant-test" } }),
|
||||
vi.mock("@/src/auth/require-order-owner", () => ({
|
||||
requireOrderOwner: vi.fn().mockResolvedValue({ owner: { tenantId: "tenant-test", subject: "buyer-test" } }),
|
||||
}));
|
||||
|
||||
import { POST } from "@/app/api/payments/[order_id]/deposit/route";
|
||||
import { getOwnedOrder } from "@/lib/orders-store";
|
||||
|
||||
const savedKey = process.env.STRIPE_SECRET_KEY;
|
||||
const savedFetch = globalThis.fetch;
|
||||
@@ -30,6 +31,7 @@ it("binds a deposit Checkout Session to its order and payment kind", async () =>
|
||||
const req = new NextRequest("https://0pt.app/api/payments/order-1/deposit", { method: "POST" });
|
||||
const res = await POST(req, { params: Promise.resolve({ order_id: "order-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect(getOwnedOrder).toHaveBeenCalledWith("order-1", "tenant-test");
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit;
|
||||
const body = init.body as URLSearchParams;
|
||||
expect(body.get("metadata[order_id]")).toBe("order-1");
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("@/lib/orders-store", () => ({
|
||||
import { POST } from "@/app/api/webhooks/stripe/route";
|
||||
|
||||
const savedWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
const owner = { tenant_id: "tenant-test", buyer_subject: "buyer-test" };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STRIPE_WEBHOOK_SECRET = "whsec_fake_local_regression";
|
||||
@@ -54,14 +55,14 @@ function session(kind: "deposit" | "balance", paymentStatus: string, amount: num
|
||||
|
||||
it("advances a matching paid deposit only after a verified event", async () => {
|
||||
session("deposit", "paid", 5000);
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 });
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 });
|
||||
|
||||
expect((await POST(delivery())).status).toBe(200);
|
||||
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "draft", "in_production");
|
||||
});
|
||||
|
||||
it("never advances an unpaid or amount-mismatched deposit", async () => {
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000, balance_ore: 5000 });
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "draft", deposit_ore: 5000, balance_ore: 5000 });
|
||||
session("deposit", "unpaid", 5000);
|
||||
expect((await POST(delivery())).status).toBe(503);
|
||||
session("deposit", "paid", 4999);
|
||||
@@ -71,8 +72,15 @@ it("never advances an unpaid or amount-mismatched deposit", async () => {
|
||||
|
||||
it("marks a matching paid balance delivered only after acceptance", async () => {
|
||||
session("balance", "paid", 5000);
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "accepted", deposit_ore: 5000, balance_ore: 5000 });
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", ...owner, status: "accepted", deposit_ore: 5000, balance_ore: 5000 });
|
||||
|
||||
expect((await POST(delivery())).status).toBe(200);
|
||||
expect(mocks.setStatusIfCurrent).toHaveBeenCalledWith("order-1", "accepted", "delivered");
|
||||
});
|
||||
|
||||
it("does not acknowledge a paid event for a legacy anonymous draft", async () => {
|
||||
session("deposit", "paid", 5000);
|
||||
mocks.getOrder.mockResolvedValue({ order_id: "order-1", status: "draft", deposit_ore: 5000 });
|
||||
expect((await POST(delivery())).status).toBe(503);
|
||||
expect(mocks.setStatusIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ routeAuth: vi.fn() }));
|
||||
vi.mock("@/src/auth/require-route-auth", () => ({ requireRouteAuth: mocks.routeAuth }));
|
||||
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
|
||||
beforeEach(() => mocks.routeAuth.mockReset());
|
||||
|
||||
const request = () => new NextRequest("https://0pt.app/api/orders", { method: "POST" });
|
||||
|
||||
it("passes through a failed Zitadel route-auth decision", async () => {
|
||||
mocks.routeAuth.mockResolvedValue({ response: NextResponse.json({ error: "unauthenticated" }, { status: 401 }) });
|
||||
expect((await requireOrderOwner(request())).response?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects the local-dev auth bypass when no verified principal exists", async () => {
|
||||
mocks.routeAuth.mockResolvedValue({ ctx: { tenantId: "spoofable-header" } });
|
||||
expect((await requireOrderOwner(request())).response?.status).toBe(401);
|
||||
});
|
||||
|
||||
it("uses only the verified JWT principal, not a request-supplied tenant", async () => {
|
||||
mocks.routeAuth.mockResolvedValue({
|
||||
ctx: { tenantId: "header-tenant", principal: { tenantId: "signed-tenant", sub: "signed-subject" } },
|
||||
});
|
||||
const result = await requireOrderOwner(request());
|
||||
expect(result.owner).toEqual({ tenantId: "signed-tenant", subject: "signed-subject" });
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createOrder, getOwnedOrder, listOwnedOrders, setOwnedStatusIfCurrent,
|
||||
IdempotencyKeyConflictError,
|
||||
} from "@/lib/orders-store";
|
||||
|
||||
const alpha = { tenantId: "tenant-alpha", subject: "buyer-alpha" };
|
||||
const beta = { tenantId: "tenant-beta", subject: "buyer-beta" };
|
||||
const config = { markets: ["SE"], volume: 1000 };
|
||||
|
||||
describe("payment-order ownership (in-memory contract)", () => {
|
||||
it("binds a new order to the verified tenant and buyer, never to client config", async () => {
|
||||
const result = await createOrder("custom", config, undefined, alpha);
|
||||
expect(result.order.tenant_id).toBe(alpha.tenantId);
|
||||
expect(result.order.buyer_subject).toBe(alpha.subject);
|
||||
expect(await getOwnedOrder(result.order.order_id, alpha.tenantId)).not.toBeNull();
|
||||
expect(await getOwnedOrder(result.order.order_id, beta.tenantId)).toBeNull();
|
||||
expect((await listOwnedOrders(beta.tenantId)).some((o) => o.order_id === result.order.order_id)).toBe(false);
|
||||
});
|
||||
|
||||
it("legacy anonymous rows remain invisible and cannot be mutated by a buyer", async () => {
|
||||
const legacy = (await createOrder("custom", config)).order;
|
||||
expect(await getOwnedOrder(legacy.order_id, alpha.tenantId)).toBeNull();
|
||||
expect(await setOwnedStatusIfCurrent(legacy.order_id, alpha.tenantId, "draft", "quoted")).toBeNull();
|
||||
});
|
||||
|
||||
it("an owner cannot change another tenant's status even at the same valid state", async () => {
|
||||
const order = (await createOrder("custom", config, undefined, alpha)).order;
|
||||
expect(await setOwnedStatusIfCurrent(order.order_id, beta.tenantId, "draft", "quoted")).toBeNull();
|
||||
expect((await getOwnedOrder(order.order_id, alpha.tenantId))?.status).toBe("draft");
|
||||
expect((await setOwnedStatusIfCurrent(order.order_id, alpha.tenantId, "draft", "quoted"))?.status).toBe("quoted");
|
||||
});
|
||||
|
||||
it("idempotency keys are tenant-namespaced, and another buyer in the same tenant cannot replay", async () => {
|
||||
const key = `ownership-${crypto.randomUUID()}`;
|
||||
const a1 = await createOrder("custom", config, key, alpha);
|
||||
const a2 = await createOrder("custom", config, key, alpha);
|
||||
const b1 = await createOrder("custom", config, key, beta);
|
||||
expect(a2.replayed).toBe(true);
|
||||
expect(a1.order.order_id).toBe(a2.order.order_id);
|
||||
expect(a1.order.order_id).not.toBe(b1.order.order_id);
|
||||
await expect(createOrder("custom", config, key, { ...alpha, subject: "other-buyer" }))
|
||||
.rejects.toBeInstanceOf(IdempotencyKeyConflictError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user