/** * ============================================================================= * UAT_Partners_Billing -- Partners portal + firm-level billing * ============================================================================= * * Covers: * PART-* : Partner portal (/partner/...) and partner-facing review routes * BILL-* : Firm billing — invoices, payments, receipts, fee structures * RBAC-* : Access control checks across both modules * SEC-* : CSRF rejection and anonymous access probes * * Required .env additions: * INVOICE_A_ID= # a seeded firm invoice id (status: draft) * PAYMENT_A_ID= # a seeded payment id against INVOICE_A_ID * PARTNER_TASK_A_ID= # a seeded service task assigned for partner review * * Tests skip gracefully when env vars are absent. * ============================================================================= */ const { test, expect } = require('@playwright/test'); require('dotenv').config(); const { BASE_URL, absoluteUrl } = require('../fixtures/url'); const { login } = require('../fixtures/auth'); const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers'); const { expectBlockedOrSafe } = require('../fixtures/assertions'); async function safeGoto(page, route) { const resp = await page.goto(route).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); return resp; } function idOr(envKey, fallback = '1') { return process.env[envKey] || fallback; } function skipIfMissing(envKey) { if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`); } // --------------------------------------------------------------------------- // Partners portal // --------------------------------------------------------------------------- test.describe('PART: Partner portal', () => { test('[V25-PART-001] PART-001 Partner dashboard loads', async ({ page }) => { await login(page, 'Partner'); const resp = await safeGoto(page, '/partner/dashboard'); await expectNoBackendError(page); expect(resp.status()).toBeLessThan(500); }); test('[V25-PART-002] PART-002 Partner client list loads', async ({ page }) => { await login(page, 'Partner'); await safeGoto(page, '/partner/clients'); await expectNoBackendError(page); }); test('[V25-PART-003] PART-003 Partner reviews list loads', async ({ page }) => { await login(page, 'Partner'); await safeGoto(page, '/partner/reviews'); await expectNoBackendError(page); }); test('[V25-PART-004] PART-004 Partner task review page loads', async ({ page }) => { skipIfMissing('PARTNER_TASK_A_ID'); await login(page, 'Partner'); await safeGoto(page, `/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`); await expectNoBackendError(page); }); test('[V25-PART-005] PART-005 Partner task review CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('PARTNER_TASK_A_ID'); const resp = await request.post( `${BASE_URL}/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`, { form: { status: 'approved', csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); test('[V25-PART-006] PART-006 Staff cannot access partner dashboard', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/partner/dashboard'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-PART-007] PART-007 Client cannot access partner reviews', async ({ page }) => { await login(page, 'Client'); const resp = await safeGoto(page, '/partner/reviews'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-PART-008] PART-008 Anonymous access to partner dashboard is blocked', async ({ page }) => { const resp = await safeGoto(page, '/partner/dashboard'); await expectBlockedOrSafe(page, resp); }); test('[V25-PART-009] PART-009 Non-existent task review ID returns safe response', async ({ page }) => { await login(page, 'Partner'); const resp = await safeGoto(page, '/partner/tasks/999999999/review'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); }); // --------------------------------------------------------------------------- // Firm billing — invoices // --------------------------------------------------------------------------- test.describe('BILL: Invoices and payments', () => { test('[V25-BILL-001] BILL-001 Invoice list loads for Firm Admin', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/billing'); await expectNoBackendError(page); }); test('[V25-BILL-002] BILL-002 Invoice create form loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/billing/invoices/new'); await expectNoBackendError(page); }); test('[V25-BILL-003] BILL-003 Invoice detail page loads', async ({ page }) => { skipIfMissing('INVOICE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}`); await expectNoBackendError(page); }); test('[V25-BILL-004] BILL-004 Invoice print page loads', async ({ page }) => { skipIfMissing('INVOICE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/print`); await expectNoBackendError(page); }); test('[V25-BILL-005] BILL-005 Invoice payments subpage loads', async ({ page }) => { skipIfMissing('INVOICE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/payments`); await expectNoBackendError(page); }); test('[V25-BILL-006] BILL-006 Payment receipt loads', async ({ page }) => { skipIfMissing('PAYMENT_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/billing/payments/${idOr('PAYMENT_A_ID')}/receipt`); await expectNoBackendError(page); }); test('[V25-BILL-007] BILL-007 Payments list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/billing/payments'); await expectNoBackendError(page); }); test('[V25-BILL-008] BILL-008 Fee structures list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/billing/fee-structures/list'); await expectNoBackendError(page); }); test('[V25-BILL-009] BILL-009 Invoice post CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('INVOICE_A_ID'); const resp = await request.post( `${BASE_URL}/billing/invoices/${idOr('INVOICE_A_ID')}/issue`, { form: { csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); test('[V25-BILL-010] BILL-010 New payment CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('INVOICE_A_ID'); const resp = await request.post( `${BASE_URL}/billing/invoices/${idOr('INVOICE_A_ID')}/payments/new`, { form: { amount: '1000', csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); test('[V25-BILL-011] BILL-011 Staff cannot access invoice list', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/billing'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-BILL-012] BILL-012 Client cannot access billing admin', async ({ page }) => { await login(page, 'Client'); const resp = await safeGoto(page, '/billing/invoices/new'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-BILL-013] BILL-013 Anonymous access to billing is blocked', async ({ page }) => { const resp = await safeGoto(page, '/billing'); await expectBlockedOrSafe(page, resp); }); test('[V25-BILL-014] BILL-014 Non-existent invoice ID returns safe response', async ({ page }) => { await login(page, 'Firm Admin'); const resp = await safeGoto(page, '/billing/invoices/999999999'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-BILL-015] BILL-015 Invoice create with blank form shows validation, not 500', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/billing/invoices/new'); const submit = page.locator('button[type="submit"], input[type="submit"]').first(); if (await submit.count()) { await submit.click().catch(() => {}); await page.waitForLoadState('domcontentloaded').catch(() => {}); } await expectNoBackendError(page); }); test('[V25-BILL-016] BILL-016 Fee structure import template download works', async ({ page }) => { await login(page, 'Firm Admin'); const [download] = await Promise.all([ page.waitForEvent('download'), page.goto('/billing/fee-structures/template').catch(() => null), ]); expect(await download.suggestedFilename()).toMatch(/fee.*structure|template|xlsx/i); }); });