BillTracker/e2e/api.probe.spec.js

121 lines
6.3 KiB
JavaScript

// 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: '<script>alert(1)</script>', 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('reconcile — Tracker and Summary report the same month obligation (B5)', async ({ request }) => {
const now = new Date();
const q = `?year=${now.getFullYear()}&month=${now.getMonth() + 1}`;
const tracker = await (await request.get(`/api/tracker${q}`)).json();
const summary = await (await request.get(`/api/summary${q}`)).json();
const trackerExpected = tracker?.summary?.total_expected ?? 0;
// Summary lists each bill; sum its display_amount for the same month.
const summaryExpected = (summary?.expenses ?? []).reduce(
(s, e) => s + (Number(e.display_amount ?? e.expected_amount) || 0),
0,
);
const round = (n) => Math.round(n * 100) / 100;
console.log(`[recon] tracker.total_expected=$${round(trackerExpected)} summary.sum=$${round(summaryExpected)}`);
expect.soft(round(summaryExpected), 'Summary bill total must reconcile with Tracker (B5)').toBe(round(trackerExpected));
});
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);
});