diff --git a/.github/workflows/testsprite.yml b/.github/workflows/testsprite.yml new file mode 100644 index 0000000..820586d --- /dev/null +++ b/.github/workflows/testsprite.yml @@ -0,0 +1,66 @@ + +# TestSprite regression gate — runs the portal suite on every pull request. +# Backend tests target the live app defined in docs/openapi.json, +# regardless of the preview deployment URL. + +name: TestSprite + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: testsprite-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + testsprite: + if: >- + github.event_name != 'pull_request' || + ( + github.event.pull_request.head.repo.full_name == github.repository && + !github.event.pull_request.draft + ) + + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install TestSprite CLI + run: npm install --global @testsprite/testsprite-cli@latest + + - name: Run TestSprite suite + env: + TESTSPRITE_API_KEY: ${{ secrets.TESTSPRITE_API_KEY }} + run: | + testsprite ci run \ + --project "aa7e92b6-93ed-43a2-8cdf-9d4889269c86" \ + --env "default" \ + --timeout 600 \ + --fail-on-skipped \ + --summary-file testsprite-summary.json + + - name: Upload TestSprite summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: testsprite-results + path: testsprite-summary.json + if-no-files-found: ignore diff --git a/README.md b/README.md index 08a7f71..cbf393f 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,9 @@ Routes: - `/playground` — the dashboard (timeline, diff, metrics, 3D city) - `/api/invoices` — the live target API that the test suite exercises -Deploy on Vercel with `framework: Next.js` and no extra configuration. Set -`SECURE_MODE` in the project environment to control the demo vulnerability. +Deploy on Vercel with `framework: Next.js` and no extra configuration. No +environment variables are required: ownership is enforced in code, not behind a +flag. --- @@ -107,7 +108,7 @@ ghostinthecodebase/ │ ├─ components/ # Dashboard, AIPanel, CodeDiff, MetricsChart, GhostCity │ ├─ components/site/ # SiteNav, SiteFooter │ ├─ data/fixture.ts # recorded demo session -│ ├─ store/invoices.ts # in-memory store + SECURE_MODE gate (BOLA) +│ ├─ store/invoices.ts # in-memory store, ownership-scoped reads │ ├─ collector/opencode.ts # OpenCode SSE adapter (stub) │ ├─ collector/shadow-git.ts # commits, diffs and rollback per turn │ ├─ runner/metrics.ts # polls /api/health into MetricSample @@ -157,19 +158,28 @@ Seed data is deterministic: users `alice` and `bob`, invoice `inv-1001` owned by ## What the suite covers -The suite is authored in the TestSprite 3.0 portal against the **deployed URL** -(never localhost) and runs on every pull request through the GitHub App. The -cases below are what it is designed to assert. +13 backend tests in the TestSprite 3.0 project, built from +[`docs/openapi.json`](docs/openapi.json) and executed against the **deployed URL** +(never localhost) by the `TestSprite` workflow on every pull request. The job is a +required check: a red suite blocks the merge. -| Case | Request | Expected when secure | -| --- | --- | --- | -| Owner reads own invoice | `GET /api/invoices/inv-1001`, `x-user-id: alice` | `200` with `inv-1001` | -| Cross-owner read (BOLA) | `GET /api/invoices/inv-1001`, `x-user-id: bob` | `403` | -| List is owner-scoped | `GET /api/invoices`, `x-user-id: bob` | `200`, no `inv-1001` | -| Unknown invoice | `GET /api/invoices/nope`, `x-user-id: alice` | `404` | -| Create invoice | `POST /api/invoices`, valid body | `201` with the new id | - -With `SECURE_MODE=off`, the cross-owner case returns `200` and the suite goes red. +| # | Case | Request | Asserts | +| --- | --- | --- | --- | +| 1 | Health probe | `GET /api/health` | `200`, `ok=true`, metrics in range | +| 2 | Leak detected | `GET /api/autopsy` | one `leak` anomaly on `mem`, severity `high` | +| 3 | Turn attribution | `GET /api/autopsy` | anomaly on `t5` blamed on `t4`, names `invoices.ts` | +| 4 | Alice lists | `GET /api/invoices`, alice | only alice's, never `inv-1002` | +| 5 | Bob lists | `GET /api/invoices`, bob | only bob's, never `inv-1001` | +| 6 | List unauthenticated | `GET /api/invoices` | `401`, no data | +| 7 | Owner reads own | `GET /api/invoices/inv-1001`, alice | `200`, seeded fields | +| 8 | Cross-owner read (BOLA) | `GET /api/invoices/inv-1001`, bob | `403`, no payload | +| 9 | Unknown id | `GET /api/invoices/inv-9999` | `404 not_found` | +| 10 | Read unauthenticated | `GET /api/invoices/inv-1001` | `401` | +| 11 | Create | `POST /api/invoices` | `201`, `USD` default, `draft` | +| 12 | Create without customer | `POST /api/invoices` | `422` | +| 13 | Create unauthenticated | `POST /api/invoices` | `401`, nothing created | + +Run the same suite locally with `npx @testsprite/testsprite-cli ci run --project aa7e92b6-93ed-43a2-8cdf-9d4889269c86 --env default`. That is the **catch** half of the demo; flipping the gate to `on` is the **green** half. No verdict is ever fabricated in this repository — the suite is authored in the portal against the deployed URL and executed by the GitHub App. diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index 5d855d4..cc9ca6f 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -19,9 +19,8 @@ export async function GET( return NextResponse.json({ error: "not_found" }, { status: 404 }); } - // In SECURE_MODE=on the store returns null for cross-owner reads; in - // SECURE_MODE=off it returns the invoice (the BOLA leak). When the invoice - // exists but is not owned, deny with 403 rather than leaking existence. + // The store reports "exists but not owned" separately from "missing", so a + // cross-owner read is denied with 403 instead of leaking existence via 404. if (!allowed) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 0000000..d8ee254 --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,874 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "GhostInTheCodebase API", + "version": "1.0.0", + "description": "Owner-scoped invoice API used as the live test target for the GhostInTheCodebase demo.\n\nAUTHENTICATION\nEvery request must send the header x-user-id. Valid values: alice, bob.\nMissing or unknown user returns 401 unauthorized.\n\nSEED DATA (deterministic, safe to assert on)\ninv-1001 belongs to alice (Acme Corp, 1200 USD, status sent)\ninv-1002 belongs to bob (Globex, 850 USD, status draft)\n\nSECURITY RULE\nA user must never read another user invoice.\nGET /api/invoices/{id} returns 403 forbidden when the invoice exists but belongs to another user,\nand 404 not_found when the id does not exist.\n\nOBSERVABILITY ENDPOINTS\nGET /api/health returns the runtime probe the metrics runner polls: ok, cpuPct, rssMb, heapUsedMb, heapTotalMb, uptimeSec, ts.\nGET /api/autopsy runs the autopsy engine over the recorded demo session and returns the detected anomalies plus one autopsy per anomaly (blamed turn, severity, root cause, heal prompt)." + }, + "servers": [ + { + "url": "https://ghostinthecodebase.vercel.app", + "description": "Production" + } + ], + "tags": [ + { + "name": "invoices", + "description": "Owner-scoped invoice access" + }, + { + "name": "health", + "description": "Runtime health probe polled by the metrics runner" + }, + { + "name": "autopsy", + "description": "Anomaly detection and turn attribution" + } + ], + "components": { + "securitySchemes": { + "userHeader": { + "type": "apiKey", + "in": "header", + "name": "x-user-id", + "description": "Caller identity header. Value: alice or bob." + } + } + }, + "paths": { + "/api/invoices": { + "get": { + "tags": [ + "invoices" + ], + "operationId": "listInvoices", + "summary": "List invoices owned by the caller", + "description": "Returns only invoices whose ownerId equals x-user-id. Must never leak another owner invoice.", + "security": [ + { + "userHeader": [] + } + ], + "parameters": [ + { + "name": "x-user-id", + "in": "header", + "required": true, + "description": "Caller identity. Valid values: alice, bob. Missing or unknown returns 401.", + "schema": { + "type": "string", + "enum": [ + "alice", + "bob" + ], + "example": "bob" + } + } + ], + "responses": { + "200": { + "description": "Owner-scoped list. For bob this contains inv-1002 and must NOT contain inv-1001.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "invoices" + ], + "properties": { + "invoices": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "ownerId", + "customer", + "amount", + "currency", + "status", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "example": "inv-1001" + }, + "ownerId": { + "type": "string", + "example": "alice" + }, + "customer": { + "type": "string", + "example": "Acme Corp" + }, + "amount": { + "type": "number", + "example": 1200 + }, + "currency": { + "type": "string", + "example": "USD" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "sent", + "paid" + ], + "example": "sent" + }, + "createdAt": { + "type": "string", + "example": "2026-10-20T08:00:00.000Z" + } + } + } + } + } + }, + "example": { + "invoices": [ + { + "id": "inv-1002", + "ownerId": "bob", + "customer": "Globex", + "amount": 850, + "currency": "USD", + "status": "draft", + "createdAt": "2026-10-20T09:00:00.000Z" + } + ] + } + } + } + }, + "401": { + "description": "Missing or unknown x-user-id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "unauthorized" + } + } + }, + "example": { + "error": "unauthorized" + } + } + } + } + } + }, + "post": { + "tags": [ + "invoices" + ], + "operationId": "createInvoice", + "summary": "Create an invoice for the caller", + "description": "Creates an invoice owned by x-user-id and returns it with status 201.", + "security": [ + { + "userHeader": [] + } + ], + "parameters": [ + { + "name": "x-user-id", + "in": "header", + "required": true, + "description": "Caller identity. Valid values: alice, bob. Missing or unknown returns 401.", + "schema": { + "type": "string", + "enum": [ + "alice", + "bob" + ], + "example": "alice" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "customer", + "amount" + ], + "properties": { + "customer": { + "type": "string", + "example": "Initech" + }, + "amount": { + "type": "number", + "example": 300 + }, + "currency": { + "type": "string", + "example": "USD" + } + } + }, + "example": { + "customer": "Initech", + "amount": 300 + } + } + } + }, + "responses": { + "201": { + "description": "Created invoice owned by the caller", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "ownerId", + "customer", + "amount", + "currency", + "status", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "example": "inv-1001" + }, + "ownerId": { + "type": "string", + "example": "alice" + }, + "customer": { + "type": "string", + "example": "Acme Corp" + }, + "amount": { + "type": "number", + "example": 1200 + }, + "currency": { + "type": "string", + "example": "USD" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "sent", + "paid" + ], + "example": "sent" + }, + "createdAt": { + "type": "string", + "example": "2026-10-20T08:00:00.000Z" + } + } + }, + "example": { + "id": "inv-4321", + "ownerId": "alice", + "customer": "Initech", + "amount": 300, + "currency": "USD", + "status": "draft", + "createdAt": "2026-10-20T12:00:00.000Z" + } + } + } + }, + "400": { + "description": "Body is not valid JSON", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid_json" + } + } + }, + "example": { + "error": "invalid_json" + } + } + } + }, + "401": { + "description": "Missing or unknown x-user-id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "unauthorized" + } + } + }, + "example": { + "error": "unauthorized" + } + } + } + }, + "422": { + "description": "customer is missing or amount is not a number", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "customer and numeric amount are required" + } + } + }, + "example": { + "error": "customer and numeric amount are required" + } + } + } + } + } + } + }, + "/api/invoices/{id}": { + "get": { + "tags": [ + "invoices" + ], + "operationId": "getInvoiceById", + "summary": "Fetch one invoice, owner-scoped", + "description": "200 when x-user-id owns the invoice. 403 when it exists but belongs to another user (BOLA guard). 404 when the id does not exist. 401 when x-user-id is missing.", + "security": [ + { + "userHeader": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Invoice id. Use inv-1001 (owned by alice) or inv-1002 (owned by bob).", + "schema": { + "type": "string", + "example": "inv-1001" + } + }, + { + "name": "x-user-id", + "in": "header", + "required": true, + "description": "Caller identity. Valid values: alice, bob. Missing or unknown returns 401.", + "schema": { + "type": "string", + "enum": [ + "alice", + "bob" + ], + "example": "bob" + } + } + ], + "responses": { + "200": { + "description": "Invoice owned by the caller", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "invoice" + ], + "properties": { + "invoice": { + "type": "object", + "required": [ + "id", + "ownerId", + "customer", + "amount", + "currency", + "status", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "example": "inv-1001" + }, + "ownerId": { + "type": "string", + "example": "alice" + }, + "customer": { + "type": "string", + "example": "Acme Corp" + }, + "amount": { + "type": "number", + "example": 1200 + }, + "currency": { + "type": "string", + "example": "USD" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "sent", + "paid" + ], + "example": "sent" + }, + "createdAt": { + "type": "string", + "example": "2026-10-20T08:00:00.000Z" + } + } + } + } + }, + "example": { + "invoice": { + "id": "inv-1001", + "ownerId": "alice", + "customer": "Acme Corp", + "amount": 1200, + "currency": "USD", + "status": "sent", + "createdAt": "2026-10-20T08:00:00.000Z" + } + } + } + } + }, + "401": { + "description": "Missing or unknown x-user-id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "unauthorized" + } + } + }, + "example": { + "error": "unauthorized" + } + } + } + }, + "403": { + "description": "Invoice exists but belongs to another user. Cross-owner read must be denied.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "forbidden" + } + } + }, + "example": { + "error": "forbidden" + } + } + } + }, + "404": { + "description": "Invoice id does not exist", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "not_found" + } + } + }, + "example": { + "error": "not_found" + } + } + } + } + } + } + }, + "/api/invoices/inv-1001": { + "get": { + "tags": [ + "invoices" + ], + "operationId": "getInvoiceInv1001", + "summary": "Fetch invoice inv-1001 (concrete seed id, owner alice)", + "description": "Concrete form of GET /api/invoices/{id} for inv-1001. alice gets 200. bob must get 403 forbidden — this is the cross-owner BOLA check.", + "security": [ + { + "userHeader": [] + } + ], + "parameters": [ + { + "name": "x-user-id", + "in": "header", + "required": true, + "description": "Caller identity. Valid values: alice, bob. Missing or unknown returns 401.", + "schema": { + "type": "string", + "enum": [ + "alice", + "bob" + ], + "example": "bob" + } + } + ], + "responses": { + "200": { + "description": "Returned only when x-user-id is alice", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "invoice" + ], + "properties": { + "invoice": { + "type": "object", + "required": [ + "id", + "ownerId", + "customer", + "amount", + "currency", + "status", + "createdAt" + ], + "properties": { + "id": { + "type": "string", + "example": "inv-1001" + }, + "ownerId": { + "type": "string", + "example": "alice" + }, + "customer": { + "type": "string", + "example": "Acme Corp" + }, + "amount": { + "type": "number", + "example": 1200 + }, + "currency": { + "type": "string", + "example": "USD" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "sent", + "paid" + ], + "example": "sent" + }, + "createdAt": { + "type": "string", + "example": "2026-10-20T08:00:00.000Z" + } + } + } + } + }, + "example": { + "invoice": { + "id": "inv-1001", + "ownerId": "alice", + "customer": "Acme Corp", + "amount": 1200, + "currency": "USD", + "status": "sent", + "createdAt": "2026-10-20T08:00:00.000Z" + } + } + } + } + }, + "401": { + "description": "Missing or unknown x-user-id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "unauthorized" + } + } + }, + "example": { + "error": "unauthorized" + } + } + } + }, + "403": { + "description": "Cross-owner read: bob requesting alice invoice must be denied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "forbidden" + } + } + }, + "example": { + "error": "forbidden" + } + } + } + }, + "404": { + "description": "Invoice id does not exist", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "not_found" + } + } + }, + "example": { + "error": "not_found" + } + } + } + } + } + } + }, + "/api/health": { + "get": { + "tags": [ + "health" + ], + "operationId": "getHealth", + "summary": "Runtime health probe", + "description": "Returns live process metrics. No auth header required. Always 200 when the app is up.", + "responses": { + "200": { + "description": "Runtime snapshot of the app process.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ok", + "cpuPct", + "rssMb", + "heapUsedMb", + "heapTotalMb", + "uptimeSec", + "ts" + ], + "properties": { + "ok": { + "type": "boolean", + "example": true + }, + "cpuPct": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "example": 3 + }, + "rssMb": { + "type": "integer", + "minimum": 0, + "example": 120 + }, + "heapUsedMb": { + "type": "integer", + "minimum": 0, + "example": 40 + }, + "heapTotalMb": { + "type": "integer", + "minimum": 0, + "example": 64 + }, + "uptimeSec": { + "type": "integer", + "minimum": 0, + "example": 900 + }, + "ts": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "/api/autopsy": { + "get": { + "tags": [ + "autopsy" + ], + "operationId": "runAutopsy", + "summary": "Run the autopsy engine over the demo session", + "description": "Deterministic: the recorded demo session always yields one memory-leak anomaly (kind \"leak\", metric \"mem\", severity \"high\") detected on turn t5 but blamed on turn t4 — the turn that added the unbounded cache in src/store/invoices.ts. healed is true because the metric recovers after the fix turn.", + "responses": { + "200": { + "description": "Detected anomalies and one autopsy per anomaly.", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "sessionId", + "anomalies", + "autopsies" + ], + "properties": { + "sessionId": { + "type": "string", + "example": "sess-demo-001" + }, + "anomalies": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "kind", + "turnId", + "severity", + "metric", + "detectedAt", + "note" + ], + "properties": { + "id": { + "type": "string", + "example": "anom-leak-4" + }, + "kind": { + "type": "string", + "example": "leak" + }, + "turnId": { + "type": "string", + "example": "t5" + }, + "severity": { + "type": "string", + "example": "high" + }, + "metric": { + "type": "string", + "example": "mem" + }, + "detectedAt": { + "type": "string", + "format": "date-time" + }, + "note": { + "type": "string", + "example": "Memory grows 38.0 MB per sample inside a 8-sample window, with no plateau." + } + } + } + }, + "autopsies": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "id", + "anomalyId", + "blamedTurnId", + "rootCause", + "fixPrompt", + "healed" + ], + "properties": { + "id": { + "type": "string", + "example": "aut-anom-leak-4" + }, + "anomalyId": { + "type": "string", + "example": "anom-leak-4" + }, + "blamedTurnId": { + "type": "string", + "example": "t4" + }, + "rootCause": { + "type": "string" + }, + "fixPrompt": { + "type": "string" + }, + "healed": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/docs/test-prd.md b/docs/test-prd.md new file mode 100644 index 0000000..6d132b6 --- /dev/null +++ b/docs/test-prd.md @@ -0,0 +1,85 @@ +# Product Requirements — Invoice API (Test Target) + +## 1. Overview + +A small, owner-scoped invoice REST API. It is the live target that the +TestSprite suite exercises. The API is deterministic: seed data is fixed, so +every assertion below is safe to automate. + +Base URL: `https://ghostinthecodebase.vercel.app` + +## 2. Users and authentication + +Two seeded users: `alice` and `bob`. + +- Every request must send the header `x-user-id` with a valid user id. +- Missing, empty or unknown `x-user-id` returns `401` with `{ "error": "unauthorized" }`. +- There are no tokens, sessions or cookies. The header is the only credential. + +## 3. Seed data + +| Invoice | Owner | Customer | Amount | Status | +| --- | --- | --- | --- | --- | +| `inv-1001` | alice | Acme Corp | 1200 | sent | +| `inv-1002` | bob | Globex | 850 | draft | + +## 4. Functional requirements + +### FR1 — List invoices + +`GET /api/invoices` + +- Returns `200` with `{ "invoices": [ ... ] }`. +- The list contains **only** invoices owned by the caller. +- For `bob`, the list contains `inv-1002` and must never contain `inv-1001`. + +### FR2 — Read a single invoice + +`GET /api/invoices/{id}` + +- `200` when the invoice belongs to the caller, body `{ "invoice": { ... } }`. +- `403` when the invoice exists but belongs to a different user, body `{ "error": "forbidden" }`. +- `404` when the id does not exist, body `{ "error": "not_found" }`. +- `401` when `x-user-id` is missing or unknown. + +### FR3 — Create an invoice + +`POST /api/invoices` + +- Body: `{ "customer": string, "amount": number, "currency"?: string }`. +- `201` with `{ "invoice": { ... } }`; the created invoice has `ownerId` equal to the caller, `status` `draft`, and `currency` defaulting to `USD`. +- `422` when `customer` is missing/empty or `amount` is not a number. +- `400` when the body is not valid JSON. +- `401` when `x-user-id` is missing or unknown. + +## 5. Security requirement (critical) + +**A user must never be able to read another user invoice.** + +This is a Broken Object Level Authorization rule (OWASP API1:2023). The +acceptance test is: `bob` requests `GET /api/invoices/inv-1001`, an invoice owned +by `alice`. + +- Pass: response is `403` and the response body does not contain the invoice data. +- Fail: response is `200`, or the body contains `inv-1001` / `Acme Corp`. + +This single case is the regression guard for the whole API. It must fail the +build when it returns `200`. + +## 6. Acceptance criteria + +| # | Request | Expected | +| --- | --- | --- | +| AC1 | `alice` GET `/api/invoices/inv-1001` | `200`, `invoice.id = inv-1001` | +| AC2 | `bob` GET `/api/invoices/inv-1001` | `403`, no invoice payload | +| AC3 | `bob` GET `/api/invoices` | `200`, list has `inv-1002`, no `inv-1001` | +| AC4 | `alice` GET `/api/invoices/inv-9999` | `404` | +| AC5 | no `x-user-id` GET `/api/invoices/inv-1001` | `401` | +| AC6 | `alice` POST `/api/invoices` with `{ "customer": "Initech", "amount": 300 }` | `201`, `ownerId = alice`, `status = draft` | +| AC7 | `alice` POST `/api/invoices` with `{ "amount": 300 }` | `422` | + +## 7. Out of scope + +The marketing site, the dashboard UI, the 3D visualisation and any +authentication flow other than the `x-user-id` header. Do not generate tests +for those surfaces in this suite. diff --git a/src/store/invoices.ts b/src/store/invoices.ts index 0ca5653..f544ba0 100644 --- a/src/store/invoices.ts +++ b/src/store/invoices.ts @@ -1,9 +1,9 @@ // In-memory store for the live target API that TestSprite pokes. // This simulates an "AI-generated app under observation": a tiny invoice -// service with one deliberate BOLA/IDOR vulnerability (OWASP API1:2023 #1) -// when SECURE_MODE is off. The vulnerability is gated by an env var so the -// TestSprite catch -> green story is reproducible without ever committing -// insecure code as the final main state. +// service that once shipped a BOLA/IDOR hole (OWASP API1:2023 #1) — a +// cross-owner read that skipped the ownership check. Ownership is now +// enforced unconditionally; the regression suite in the TestSprite project +// pins that behaviour so it cannot come back. export interface User { id: string; @@ -55,11 +55,6 @@ const store: { users: Map; invoices: Map } = { invoices: new Map(SEED_INVOICES.map((i) => [i.id, i])), }; -/** When off, GET /api/invoices/:id skips the ownership check (the BOLA bug). */ -export function isSecureMode(): boolean { - return process.env.SECURE_MODE === "on"; -} - export function getUser(id: string): User | undefined { return store.users.get(id); } @@ -87,10 +82,12 @@ export function createInvoice( } /** - * Fetch a single invoice. + * Fetch a single invoice, scoped to the caller. * - * SECURE_MODE=off -> returns ANY invoice regardless of owner (BOLA leak). - * SECURE_MODE=on -> enforces ownership; returns null when not allowed. + * Ownership is always enforced: a caller that does not own the invoice gets + * `allowed: false`, which the route turns into 403 without leaking the payload. + * The "exists but forbidden" (403) vs "not found" (404) split is kept so the + * route never leaks whether an id exists to a caller who cannot read it. */ export function getInvoice( id: string, @@ -99,14 +96,6 @@ export function getInvoice( const invoice = store.invoices.get(id); if (!invoice) return { invoice: null, allowed: false }; - if (!isSecureMode()) { - // matcha:debt deliberate BOLA vulnerability for the TestSprite catch demo. - // Remove (or keep SECURE_MODE=on) for the fixed state. Fix when main flips. - return { invoice, allowed: true }; - } - - // Secure mode: distinguish "exists but forbidden" (403) from "not found" (404) - // so the route can deny ownership explicitly instead of leaking existence. const allowed = invoice.ownerId === requestingUserId; return { invoice, allowed }; }