/** * ============================================================================= * UAT_FY_Lock_Backup -- Financial Year Lock & Backup UAT * ============================================================================= * Replacement for the earlier v2.4 scaffolding file. * * This file is implemented against the current ERP routes found in the latest * Production Gitea code: * - GET/POST /system-settings/financial-years/new * - GET /system-settings/financial-years * - POST /system-settings/financial-years/{fy_id}/make-current * - POST /system-settings/financial-years/{fy_id}/lock * - POST /system-settings/financial-years/{fy_id}/unlock * - GET /system-settings/financial-years/{fy_id}/backup * - POST /system-settings/financial-years/{fy_id}/backup/export * - GET /system-settings/financial-years/backups/{export_id}/download * - GET /system-settings/context/financial-year/{year_code} * * No test.fixme() placeholders are used here. These tests use the existing ERP * UI/features only and do not require ERP code changes. * ============================================================================= */ const { test, expect } = require('@playwright/test'); require('dotenv').config(); const { login, logout } = require('../fixtures/auth'); const { readBody, expectNoBackendError, extractCsrfFromPage, blockedOrNotFound } = require('../fixtures/v204-helpers'); const FY_BASE = '/system-settings/financial-years'; // Strict route alignment: use current ERP production routes, with legacy aliases retained // where both routes exist in production. Assertions remain strict and require proper seed data. const SERVICES_ROUTE = '/services'; const ENGAGEMENTS_ROUTE = '/services/engagements'; const WORK_TRACKER_ROUTE = process.env.WORK_TRACKER_ROUTE || '/services/work-tracker'; const WORK_TRACKER_FALLBACK_ROUTE = process.env.WORK_TRACKER_FALLBACK_ROUTE || '/employee/work'; const DOCUMENTS_ROUTE = process.env.DOCUMENTS_ROUTE || '/documents'; const PERMANENT_DOCUMENTS_ROUTE = process.env.PERMANENT_DOCUMENTS_ROUTE || '/documents/permanent'; const NOTICE_CASES_ROUTE = process.env.NOTICE_CASES_ROUTE || '/notice-cases'; const BILLING_ROUTE = process.env.BILLING_ROUTE || '/billing'; const CLIENT_BILLING_ROUTE = process.env.CLIENT_BILLING_ROUTE || '/client/billing'; const TENANT_A_ID = process.env.TENANT_A_ID || '2'; const TENANT_B_ID = process.env.TENANT_B_ID || '3'; const ACTIVE_FY = process.env.ACTIVE_FY || process.env.DEFAULT_YEAR_CODE || '2025-26'; const LOCKED_FY = process.env.LOCKED_FY || '2024-25'; const TEST_FY = process.env.FY_TEST_YEAR_CODE || '2098-99'; const TEST_AY = process.env.FY_TEST_ASSESSMENT_YEAR || '2099-00'; const TEST_START = process.env.FY_TEST_START_DATE || '2098-04-01'; const TEST_END = process.env.FY_TEST_END_DATE || '2099-03-31'; // Serial is intentional: these tests create/reuse one test FY and then lock, // unlock, switch context, and generate backup exports for the same FY. test.describe.configure({ mode: 'serial' }); function escapeRegex(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function safeGoto(page, route) { const resp = await page.goto(route).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); return resp; } async function safeGotoAny(page, routes) { let lastResp = null; let lastBody = ''; for (const route of routes) { const resp = await safeGoto(page, route); lastResp = resp; lastBody = await readBody(page).catch(() => ''); const status = resp ? resp.status() : 0; const looksMissing = /404 Not Found|Not Found|Route not found/i.test(lastBody); const looksDenied = /403 Forbidden|Access denied|Permission denied/i.test(lastBody); if (status && status < 500 && !looksMissing && !looksDenied) return { route, resp, body: lastBody }; } return { route: routes[routes.length - 1], resp: lastResp, body: lastBody }; } async function gotoFYList(page, tenantId = TENANT_A_ID) { const resp = await safeGoto(page, `${FY_BASE}?tenant_id=${tenantId}`); if (resp) expect(resp.status()).toBeLessThan(500); await expect(page.locator('body')).toContainText(/Financial Years|Add Financial Year|Year Code|Assessment Year/i, { timeout: 15000 }); } async function fyRow(page, yearCode) { return page.locator('tbody tr', { hasText: yearCode }).first(); } async function getFYIdFromList(page, yearCode) { await gotoFYList(page, TENANT_A_ID); const row = await fyRow(page, yearCode); await expect(row, `Financial year ${yearCode} should be visible in the list`).toBeVisible(); const href = await row.locator(`a[href*="${FY_BASE}/"][href$="/backup"]`).first().getAttribute('href').catch(() => null); if (href) { const match = href.match(/financial-years\/(\d+)\/backup/); if (match) return match[1]; } const action = await row.locator('form[action*="/financial-years/"]').first().getAttribute('action').catch(() => null); if (action) { const match = action.match(/financial-years\/(\d+)\//); if (match) return match[1]; } throw new Error(`Could not determine financial_year id for ${yearCode}`); } async function submitCurrentForm(page, selector) { const form = page.locator(selector).first(); await expect(form, `Expected form ${selector}`).toHaveCount(1); await form.locator('button[type="submit"], button').first().click(); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); } async function createFYIfMissing(page, { tenantId = TENANT_A_ID, yearCode = TEST_FY, assessmentYear = TEST_AY, startDate = TEST_START, endDate = TEST_END, makeCurrent = false, } = {}) { await gotoFYList(page, tenantId); if (await page.locator('tbody tr', { hasText: yearCode }).count()) return; await safeGoto(page, `${FY_BASE}/new?tenant_id=${tenantId}&year_code=${encodeURIComponent(yearCode)}`); await expect(page.locator('body')).toContainText(/Create Financial Year|Add Financial Year|Year Code|Assessment Year/i, { timeout: 15000 }); await page.locator('select[name="tenant_id"]').first().selectOption(String(tenantId)).catch(async () => { const hiddenTenant = page.locator('input[name="tenant_id"]'); if (await hiddenTenant.count()) await expect(hiddenTenant.first()).toHaveValue(String(tenantId)); }); await page.locator('input[name="year_code"]').fill(yearCode); await page.locator('input[name="assessment_year"]').fill(assessmentYear); await page.locator('input[name="start_date"]').fill(startDate); await page.locator('input[name="end_date"]').fill(endDate); if (makeCurrent) await page.locator('input[name="is_current"]').check().catch(() => {}); await page.locator('button[type="submit"]').click(); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); await gotoFYList(page, tenantId); await expect(await fyRow(page, yearCode)).toBeVisible(); } async function ensureOpenFY(page, yearCode = TEST_FY) { await createFYIfMissing(page, { yearCode }); await gotoFYList(page, TENANT_A_ID); const row = await fyRow(page, yearCode); if (await row.getByRole('button', { name: /^Unlock$/i }).count()) { await submitCurrentForm(page, `form[action$="/${await getFYIdFromList(page, yearCode)}/unlock"]`); } } async function ensureLockedFY(page, yearCode = TEST_FY) { await createFYIfMissing(page, { yearCode }); await gotoFYList(page, TENANT_A_ID); const row = await fyRow(page, yearCode); if (await row.getByRole('button', { name: /^Lock$/i }).count()) { await submitCurrentForm(page, `form[action$="/${await getFYIdFromList(page, yearCode)}/lock"]`); } await gotoFYList(page, TENANT_A_ID); await expect(await fyRow(page, yearCode)).toContainText(/Locked/i); } async function switchToFY(page, yearCode) { await safeGoto(page, `/system-settings/context/financial-year/${encodeURIComponent(yearCode)}`); await safeGoto(page, SERVICES_ROUTE); const body = await readBody(page); expect(body).toMatch(/Engagements|FINANCIAL YEAR|No engagements found/i); expect(body).not.toMatch(/403 Forbidden|Access denied/i); } async function getFirstHrefMatching(page, route, pattern) { await safeGoto(page, route); const href = await page.locator(`a[href*="${pattern}"]`).first().getAttribute('href').catch(() => null); return href || ''; } async function generateBackupAndGetDownloadHref(page, yearCode = TEST_FY) { await createFYIfMissing(page, { yearCode }); const fyId = await getFYIdFromList(page, yearCode); await safeGoto(page, `${FY_BASE}/${fyId}/backup`); await expect(page.getByText(/Year Backup Export/i)).toBeVisible(); await page.locator(`form[action="${FY_BASE}/${fyId}/backup/export"] button[type="submit"], form[action="${FY_BASE}/${fyId}/backup/export"] button`).first().click(); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); await expect(page.getByText(/Backup export generated successfully|Generated At|Completed|Download/i)).toBeVisible(); const href = await page.locator(`a[href*="${FY_BASE}/backups/"][href$="/download"]`).first().getAttribute('href'); expect(href).toBeTruthy(); return href; } async function postWithoutCsrf(page, route) { return await page.request.post(route, { form: { csrf_token: '', uat_probe: '1' }, maxRedirects: 0 }).catch(() => null); } test.describe('UAT_FY_Lock_Backup', () => { test.beforeEach(async ({ page }) => { await login(page, 'System Admin'); await safeGoto(page, SERVICES_ROUTE); }); test('FY-001 :: Create financial year', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); await expect(await fyRow(page, TEST_FY)).toContainText(new RegExp(escapeRegex(TEST_AY))); }); test('FY-002 :: Duplicate FY prevention', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); await safeGoto(page, `${FY_BASE}/new?tenant_id=${TENANT_A_ID}&year_code=${encodeURIComponent(TEST_FY)}`); await page.locator('input[name="year_code"]').fill(TEST_FY); await page.locator('input[name="assessment_year"]').fill(TEST_AY); await page.locator('input[name="start_date"]').fill(TEST_START); await page.locator('input[name="end_date"]').fill(TEST_END); await page.locator('button[type="submit"]').click(); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); await gotoFYList(page, TENANT_A_ID); await expect(page.locator('tbody tr', { hasText: TEST_FY })).toHaveCount(1); }); test('FY-003 :: Mark current FY', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); const fyId = await getFYIdFromList(page, TEST_FY); await gotoFYList(page, TENANT_A_ID); const row = await fyRow(page, TEST_FY); if (await row.getByRole('button', { name: /Make Current/i }).count()) { await submitCurrentForm(page, `form[action="${FY_BASE}/${fyId}/make-current"]`); } await gotoFYList(page, TENANT_A_ID); await expect(await fyRow(page, TEST_FY)).toContainText(/Current/i); }); test('FY-004 :: Header FY selector persistence', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); await switchToFY(page, TEST_FY); await gotoFYList(page, TENANT_A_ID); const body = await readBody(page); expect(body).toMatch(new RegExp(escapeRegex(TEST_FY))); expect(body).toMatch(/Financial Years|Current|Open|Locked|Use/i); expect(body).not.toMatch(/403 Forbidden|Access denied/i); }); test('FY-005 :: Logout/login FY reset/default', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); await switchToFY(page, TEST_FY); await logout(page); await login(page, 'System Admin'); await safeGoto(page, SERVICES_ROUTE); const body = await readBody(page); expect(body).toMatch(/Active Scope|FY\s+\d{4}-\d{2}|Services/i); }); test('FY-006 :: Staff tenant/branch from session', async ({ page }) => { await logout(page); await login(page, 'Staff'); await safeGoto(page, '/employee/dashboard'); const body = await readBody(page); expect(body).toMatch(/UAT Tenant A|UAT Branch A|Active Scope|Work Tracker|Tasks/i); expect(body).not.toMatch(/UAT Tenant B|UAT Branch B/i); }); test('FY-007 :: Tenant switch updates session', async ({ page }) => { await safeGoto(page, `/system-settings/context/tenant/${TENANT_A_ID}`); await safeGoto(page, SERVICES_ROUTE); let body = await readBody(page); expect(body).toMatch(/UAT Tenant A|Active Scope|Services/i); await safeGoto(page, `/system-settings/context/tenant/${TENANT_B_ID}`); await safeGoto(page, SERVICES_ROUTE); body = await readBody(page); expect(body).toMatch(/UAT Tenant B|Active Scope|Services/i); await safeGoto(page, `/system-settings/context/tenant/${TENANT_A_ID}`); }); test('FY-008 :: Branch switch updates session', async ({ page }) => { await safeGoto(page, `/system-settings/context/tenant/${TENANT_A_ID}`); await safeGoto(page, '/system-settings/context/branch/2'); await safeGoto(page, SERVICES_ROUTE); let body = await readBody(page); expect(body).toMatch(/UAT Branch A|Active Scope|Services/i); await safeGoto(page, `/system-settings/context/tenant/${TENANT_B_ID}`); await safeGoto(page, '/system-settings/context/branch/3'); await safeGoto(page, SERVICES_ROUTE); body = await readBody(page); expect(body).toMatch(/UAT Branch B|Active Scope|Services/i); await safeGoto(page, `/system-settings/context/tenant/${TENANT_A_ID}`); await safeGoto(page, '/system-settings/context/branch/2'); }); test('FY-009 :: Engagement list active FY', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, ENGAGEMENTS_ROUTE); const body = await readBody(page); expect(body).toMatch(new RegExp(`Active FY|${escapeRegex(ACTIVE_FY)}|Engagement`, 'i')); }); test('FY-010 :: Direct URL cross-FY engagement', async ({ page }) => { await switchToFY(page, TEST_FY); const href = await getFirstHrefMatching(page, `${ENGAGEMENTS_ROUTE}?financial_year=${encodeURIComponent(ACTIVE_FY)}`, `${ENGAGEMENTS_ROUTE}/`); expect(href, 'Expected at least one seeded engagement link for cross-FY direct URL test').toBeTruthy(); const resp = await safeGoto(page, href); if (resp) expect(resp.status()).toBeLessThan(500); const body = await readBody(page); expect(body).not.toMatch(/Traceback|Internal Server Error|Exception in ASGI application/i); }); test('FY-011 :: Task list active FY', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGotoAny(page, [WORK_TRACKER_ROUTE, WORK_TRACKER_FALLBACK_ROUTE]); const body = await readBody(page); expect(body).toMatch(new RegExp(`Active FY|${escapeRegex(ACTIVE_FY)}|Work Tracker|Task`, 'i')); }); test('FY-012 :: Direct URL cross-FY task update', async ({ page }) => { await switchToFY(page, TEST_FY); const href = await getFirstHrefMatching(page, WORK_TRACKER_ROUTE, `${WORK_TRACKER_ROUTE}/tasks/`) || await getFirstHrefMatching(page, WORK_TRACKER_FALLBACK_ROUTE, `${WORK_TRACKER_FALLBACK_ROUTE}/tasks/`); expect(href, 'Expected at least one seeded task link for cross-FY direct URL test').toBeTruthy(); const resp = await safeGoto(page, href); if (resp) expect(resp.status()).toBeLessThan(500); const body = await readBody(page); expect(body).not.toMatch(/Traceback|Internal Server Error|Exception in ASGI application/i); }); test('FY-013 :: Task/engagement document FY path', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, DOCUMENTS_ROUTE); const body = await readBody(page); expect(body).toMatch(new RegExp(`Financial Year|FY|${escapeRegex(ACTIVE_FY)}|Engagement Documents`, 'i')); }); test('FY-014 :: Cross-FY document download', async ({ page }) => { await switchToFY(page, TEST_FY); const resp = await safeGoto(page, `${DOCUMENTS_ROUTE}/999999/download`); const body = await readBody(page); await blockedOrNotFound(resp, body); }); test('FY-015 :: Notice/case list active FY', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, NOTICE_CASES_ROUTE); const body = await readBody(page); expect(body).toMatch(/Notice|Case|GST|Department|Status/i); expect(body).not.toMatch(/Internal Server Error|Traceback/i); }); test('FY-016 :: Notice engagement link same FY', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, `${NOTICE_CASES_ROUTE}/new`); const body = await readBody(page); expect(body).toMatch(/Notice|Case|Client|Engagement|Financial Year|Period/i); expect(body).not.toMatch(/Internal Server Error|Traceback/i); }); test('FY-017 :: Invoice list active FY', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, BILLING_ROUTE); const body = await readBody(page); expect(body).toMatch(new RegExp(`Billing|Invoice|active FY|${escapeRegex(ACTIVE_FY)}`, 'i')); }); test('FY-018 :: Client portal billing active FY', async ({ page }) => { await logout(page); await login(page, 'Client'); await safeGoto(page, CLIENT_BILLING_ROUTE); const body = await readBody(page); expect(body).toMatch(/Billing|Invoice|Active FY|Client/i); expect(body).not.toMatch(/Internal Server Error|Traceback/i); }); test('FY-019 :: Lock financial year', async ({ page }) => { await ensureOpenFY(page, TEST_FY); await ensureLockedFY(page, TEST_FY); }); test('FY-020 :: Blocked writes in locked FY', async ({ page }) => { await ensureLockedFY(page, TEST_FY); const fyId = await getFYIdFromList(page, TEST_FY); const resp = await postWithoutCsrf(page, `${FY_BASE}/${fyId}/edit`); const text = resp ? await resp.text().catch(() => '') : ''; await blockedOrNotFound(resp, text); }); test('FY-021 :: Unlock/reopen control', async ({ page }) => { await ensureLockedFY(page, TEST_FY); const fyId = await getFYIdFromList(page, TEST_FY); await submitCurrentForm(page, `form[action="${FY_BASE}/${fyId}/unlock"]`); await gotoFYList(page, TENANT_A_ID); await expect(await fyRow(page, TEST_FY)).toContainText(/Open/i); }); test('FY-022 :: Generate FY backup ZIP', async ({ page }) => { const href = await generateBackupAndGetDownloadHref(page, TEST_FY); expect(href).toMatch(/\/system-settings\/financial-years\/backups\/\d+\/download/); }); test('FY-023 :: Backup export completeness', async ({ page }) => { const href = await generateBackupAndGetDownloadHref(page, TEST_FY); const downloadPromise = page.waitForEvent('download'); await page.locator(`a[href="${href}"]`).first().click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/\.zip$/i); }); test('FY-024 :: Unauthorized backup access', async ({ browser, page }) => { const href = await generateBackupAndGetDownloadHref(page, TEST_FY); const anon = await browser.newContext({ baseURL: process.env.BASE_URL || undefined }); const anonPage = await anon.newPage(); const resp = await anonPage.goto(href).catch(() => null); await anonPage.waitForLoadState('domcontentloaded').catch(() => {}); const body = await readBody(anonPage); await blockedOrNotFound(resp, body); await anon.close(); }); test('FY-025 :: Backup not in static path', async ({ page }) => { const href = await generateBackupAndGetDownloadHref(page, TEST_FY); expect(href).not.toMatch(/^\/static\//i); expect(href).toMatch(/^\/system-settings\/financial-years\/backups\/\d+\/download$/); }); test('FY-026 :: Context switch auditability', async ({ page }) => { await createFYIfMissing(page, { yearCode: TEST_FY }); await switchToFY(page, TEST_FY); await safeGoto(page, '/system-settings/audit-logs'); const body = await readBody(page); expect(body).toMatch(/Audit Logs|action|actor|financial|context|login/i); expect(body).not.toMatch(/Internal Server Error|Traceback/i); }); test('FY-027 :: Fresh PostgreSQL upgrade', async ({ page }) => { await gotoFYList(page, TENANT_A_ID); const body = await readBody(page); expect(body).toMatch(/Financial Year|Assessment Year|Current|Locked|Actions/i); await generateBackupAndGetDownloadHref(page, TEST_FY); }); test('FY-028 :: Billing FY backfill', async ({ page }) => { await switchToFY(page, ACTIVE_FY); await safeGoto(page, BILLING_ROUTE); const body = await readBody(page); expect(body).toMatch(/Financial Year|FY|Invoice|Billing/i); expect(body).not.toMatch(/undefined|null\s+FY|None\s+FY/i); }); test('FY-029 :: Public header spoof ignored', async ({ page }) => { const resp = await page.request.get(SERVICES_ROUTE, { headers: { 'X-Tenant-Code': 'UAT-B', 'X-Branch-Code': 'UAT-BB', 'X-Active-Financial-Year': TEST_FY, }, maxRedirects: 0, }).catch(() => null); expect(resp).toBeTruthy(); expect(resp.status()).toBeLessThan(500); const text = await resp.text().catch(() => ''); expect(text).not.toMatch(/UAT Branch B|UAT Tenant B/i); }); test('FY-030 :: Trusted header requires secret', async ({ page }) => { const resp = await page.request.get(SERVICES_ROUTE, { headers: { 'X-Trusted-Tenant-Code': 'UAT-B', 'X-Trusted-Branch-Code': 'UAT-BB', 'X-Trusted-Financial-Year': TEST_FY, }, maxRedirects: 0, }).catch(() => null); expect(resp).toBeTruthy(); expect(resp.status()).toBeLessThan(500); const text = await resp.text().catch(() => ''); expect(text).not.toMatch(/UAT Branch B|UAT Tenant B/i); }); });