153 lines
8.1 KiB
JavaScript
153 lines
8.1 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('authorization — a regular user is blocked from admin + status APIs (B1/B11)', async ({ request }) => {
|
|
const token = await csrf(request);
|
|
// Read endpoints → 403 (requireAdmin); user A is a regular 'user'.
|
|
for (const url of ['/api/admin/users', '/api/admin/has-users', '/api/status', '/api/about-admin']) {
|
|
const res = await request.get(url);
|
|
console.log(`[authz] GET ${url} -> ${res.status()}`);
|
|
expect.soft(res.status(), `${url} must be admin-only (403)`).toBe(403);
|
|
}
|
|
// Write endpoint → 403 too (not a silent success).
|
|
const create = await request.post('/api/admin/users', {
|
|
headers: { 'x-csrf-token': token },
|
|
data: { username: 'sneaky_admin', password: 'password123', role: 'admin' },
|
|
});
|
|
console.log(`[authz] POST /api/admin/users -> ${create.status()}`);
|
|
expect.soft(create.status(), 'creating a user as a non-admin must be 403').toBe(403);
|
|
});
|
|
|
|
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('reconcile — Analytics expected line gates by occurrence like the Tracker (B5)', async ({ request }) => {
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = now.getMonth() + 1;
|
|
const tracker = await (await request.get(`/api/tracker?year=${y}&month=${m}`)).json();
|
|
const analytics = await (await request.get(`/api/analytics/summary?year=${y}&month=${m}&months=12`)).json();
|
|
|
|
const key = `${y}-${String(m).padStart(2, '0')}`;
|
|
const row = (analytics?.expected_vs_actual ?? []).find((r) => r.month === key);
|
|
const trackerExpected = Math.round((tracker?.summary?.total_expected ?? 0) * 100) / 100;
|
|
const analyticsExpected = Math.round((row?.expected ?? 0) * 100) / 100;
|
|
console.log(`[recon] analytics.expected[${key}]=$${analyticsExpected} tracker.total_expected=$${trackerExpected}`);
|
|
expect.soft(analyticsExpected, 'Analytics expected must gate by occurrence (QA-B5-01 family)').toBe(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);
|
|
});
|