// Adversarial API probe (QA_PLAN B13 + B1). Find-mode: drives the real API as an // authenticated user (user A) and observes responses — logs everything, and // soft-asserts the two invariants that would be S1/S2 if violated: // 1. no request produces a 500 (must be a structured 4xx instead), and // 2. user A cannot read/modify user B's resources (data-isolation / IDOR). // Runs on one project only; the DB (with user B + fixture) is built by prepare-db. const fs = require('fs'); const path = require('path'); const { test, expect } = require('@playwright/test'); const { STORAGE_STATE } = require('./constants'); test.use({ storageState: STORAGE_STATE }); const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '.auth', 'fixture.json'), 'utf8')); async function csrf(request) { const r = await request.get('/api/auth/csrf-token'); return (await r.json()).token; } test('validation & error shapes — reject bad input, never a raw 500 (B13)', async ({ request }) => { const token = await csrf(request); const post = (data) => request.post('/api/bills', { headers: { 'x-csrf-token': token }, data }); // [payload, expected: 'reject' (4xx) or 'accept' (2xx)] const cases = { 'empty body': [{}, 'reject'], 'missing name': [{ expected_amount: 10, due_day: 5 }, 'reject'], 'negative amount': [{ name: 'Neg', expected_amount: -100, due_day: 5 }, 'reject'], // QA-B13-01 'huge amount': [{ name: 'Huge', expected_amount: 1e15, due_day: 5 }, 'reject'], // QA-B13-01 'non-numeric amount': [{ name: 'NaN', expected_amount: 'abc', due_day: 5 }, 'reject'], // QA-B13-01 'due_day 0': [{ name: 'D0', expected_amount: 10, due_day: 0 }, 'reject'], 'due_day 99': [{ name: 'D99', expected_amount: 10, due_day: 99 }, 'reject'], 'due_day negative': [{ name: 'Dn', expected_amount: 10, due_day: -3 }, 'reject'], 'zero amount (valid)': [{ name: 'Zero', expected_amount: 0, due_day: 5 }, 'accept'], 'large valid amount': [{ name: 'Big', expected_amount: 9999999.99, due_day: 5 }, 'accept'], 'xss name (stored inert)': [{ name: '', expected_amount: 10, due_day: 5 }, 'accept'], }; for (const [label, [data, expected]] of Object.entries(cases)) { const res = await post(data); const body = await res.text(); console.log(`[validate] ${label.padEnd(24)} -> ${res.status()} ${body.slice(0, 120)}`); expect.soft(res.status(), `"${label}" must never 500`).toBeLessThan(500); if (expected === 'reject') { expect.soft(res.status(), `"${label}" should be rejected (4xx)`).toBeGreaterThanOrEqual(400); } else { expect.soft(res.status(), `"${label}" should be accepted (2xx)`).toBeLessThan(300); } } }); test('data-isolation — cannot touch another user\'s bill (B1/IDOR)', async ({ request }) => { const token = await csrf(request); const id = fixture.userBBillId; expect(id, 'fixture must provide a user B bill id').toBeTruthy(); const get = await request.get(`/api/bills/${id}`); console.log(`[idor] GET /api/bills/${id} -> ${get.status()}`); expect.soft(get.status(), 'GET user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400); const put = await request.put(`/api/bills/${id}`, { headers: { 'x-csrf-token': token }, data: { name: 'HACKED', expected_amount: 1, due_day: 1 }, }); console.log(`[idor] PUT /api/bills/${id} -> ${put.status()}`); expect.soft(put.status(), 'PUT user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400); const del = await request.delete(`/api/bills/${id}`, { headers: { 'x-csrf-token': token } }); console.log(`[idor] DELETE /api/bills/${id} -> ${del.status()}`); expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400); }); test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => { for (const id of ['99999999', 'not-a-number', '0', '-1']) { const res = await request.get(`/api/bills/${id}`); const body = await res.text(); console.log(`[errshape] GET /api/bills/${id} -> ${res.status()} ${body.slice(0, 100)}`); expect.soft(res.status(), `id=${id} must not 500`).toBeLessThan(500); } }); test('CSRF — a state-changing request without a token is rejected (B13)', async ({ request }) => { const res = await request.post('/api/bills', { data: { name: 'NoCSRF', expected_amount: 10, due_day: 5 } }); console.log(`[csrf] POST /api/bills (no token) -> ${res.status()}`); expect.soft(res.status(), 'missing CSRF token must be rejected (403)').toBe(403); }); test('seed demo data stores amounts in the correct unit — cents, not dollars (B9)', async ({ request }) => { // QA-B9-01: POST /api/user/seed-demo-data must produce realistic amounts. The // seed inserts dollars into the integer-cents expected_amount column (regression // since the v1.03 cents migration), so a seeded "$85" bill shows as $0.85. // We assert the *total* obligation for the seeded month is sane (>$100), which // fails while the 100x bug is present and passes once fixed. const now = new Date(); const res = await request.get(`/api/tracker?year=${now.getFullYear()}&month=${now.getMonth() + 1}`); const body = await res.json(); const totalExpected = body?.summary?.total_expected ?? 0; console.log(`[seed] tracker total_expected = $${totalExpected} (seeded month)`); expect .soft(totalExpected, 'seeded monthly bills should total > $100, not cents (QA-B9-01)') .toBeGreaterThan(100); });