From 74b85002622a78b317bb1a04c2bd5f778b815c14 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:06:54 +0000 Subject: [PATCH 1/8] Add technical planning for VIBE-310: Blob Explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated specification and implementation plan for blob explorer feature allowing system admins to view publication metadata and manually trigger re-submissions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/tickets/VIBE-310/plan.md | 230 ++++++++++++++++++++++ docs/tickets/VIBE-310/specification.md | 258 +++++++++++++++++++++++++ 2 files changed, 488 insertions(+) create mode 100644 docs/tickets/VIBE-310/plan.md create mode 100644 docs/tickets/VIBE-310/specification.md diff --git a/docs/tickets/VIBE-310/plan.md b/docs/tickets/VIBE-310/plan.md new file mode 100644 index 00000000..4735688b --- /dev/null +++ b/docs/tickets/VIBE-310/plan.md @@ -0,0 +1,230 @@ +# VIBE-310: Technical Implementation Plan + +## Overview +This ticket implements a Blob Explorer feature for System Admin users to view publication metadata and manually trigger re-submission of publications to subscribers. The feature includes six pages navigating from a dashboard tile through location and publication browsing to a confirmation workflow. + +## Summary +The Blob Explorer allows system administrators to browse publications by location, view detailed metadata for both JSON and flat file publications, and manually trigger subscription notifications for any publication. The implementation requires database queries for locations and publications, blob storage integration for file access, and a subscription notification service for re-submissions. + +## Architecture + +### Database Requirements +- Query locations (venues) with publication counts +- Query publications by location with metadata (artefact_id, list_type, display_from, display_to) +- Retrieve full publication metadata including: + - Artefact ID, Location ID, Location Name + - Publication Type, List Type, Provenance + - Language, Sensitivity, Content Date + - Display From/To dates + +### Blob Storage Integration +- Access JSON publication content +- Access flat file publications (PDF, Word documents) +- Render JSON publications using templates +- Serve flat files (PDF pop-out, Word download) + +### Subscription Service +- Trigger manual re-submission for a publication +- Send notifications to all subscribers of that publication +- Prevent duplicate submissions (POST/Redirect/GET pattern) + +## Module Structure + +Create new module: `libs/blob-explorer` + +``` +libs/blob-explorer/ +├── package.json +├── tsconfig.json +├── prisma/ +│ └── schema.prisma # If new tables needed +└── src/ + ├── index.ts # Business logic exports + ├── config.ts # Module configuration + ├── pages/ + │ ├── blob-explorer-locations.ts + │ ├── blob-explorer-locations.njk + │ ├── blob-explorer-publications.ts + │ ├── blob-explorer-publications.njk + │ ├── blob-explorer-json-file.ts + │ ├── blob-explorer-json-file.njk + │ ├── blob-explorer-flat-file.ts + │ ├── blob-explorer-flat-file.njk + │ ├── confirm-resubmission.ts + │ ├── confirm-resubmission.njk + │ ├── resubmission-success.ts + │ └── resubmission-success.njk + ├── services/ + │ ├── location-service.ts + │ ├── publication-service.ts + │ └── resubmission-service.ts + └── locales/ + ├── en.ts + └── cy.ts +``` + +## Implementation Tasks + +### 1. Module Setup +- Create `libs/blob-explorer` directory structure +- Configure package.json with build scripts +- Add TypeScript configuration +- Register module in root tsconfig.json paths +- Register module in apps/web/src/app.ts + +### 2. System Admin Dashboard Enhancement +- Add Blob Explorer tile to existing dashboard +- Update dashboard page controller to include tile +- Add tile routing to blob-explorer-locations +- Ensure tile visible only to System Admin users + +### 3. Database Services +**location-service.ts:** +- `getLocationsWithPublicationCount()` - Query all locations with publication counts + +**publication-service.ts:** +- `getPublicationsByLocation(locationId)` - Query publications for a location +- `getPublicationMetadata(artefactId)` - Retrieve full metadata for a publication +- `getPublicationType(artefactId)` - Determine if JSON or flat file +- `getJsonContent(artefactId)` - Retrieve raw JSON content +- `getRenderedTemplate(artefactId)` - Get rendered publication HTML +- `getFlatFileUrl(artefactId)` - Get blob storage URL for flat file + +**resubmission-service.ts:** +- `triggerResubmission(artefactId)` - Trigger subscription notifications for publication +- `getSubscribersForPublication(artefactId)` - Get all subscribers for a publication +- `sendNotifications(subscribers, publication)` - Send notifications to subscribers + +### 4. Page Controllers and Templates + +**blob-explorer-locations (Page 2):** +- GET: Render locations table with publication counts +- Use location-service to fetch data +- Display table with Location and Number of publications columns +- Make rows clickable to navigate to publications page + +**blob-explorer-publications (Page 3):** +- GET: Render publications table for selected location +- Query parameter: locationId +- Use publication-service to fetch publications +- Display Artefact ID (link), List Type, Display From, Display To +- Link Artefact IDs to appropriate file page (JSON or flat file) + +**blob-explorer-json-file (Page 4A):** +- GET: Render JSON publication details +- Query parameter: artefactId +- Fetch and display metadata table +- Provide link to rendered template +- Implement accordion for raw JSON content +- Show green "Re-submit subscription" button +- POST: Navigate to confirm-resubmission page + +**blob-explorer-flat-file (Page 4B):** +- GET: Render flat file publication details +- Query parameter: artefactId +- Fetch and display metadata table +- Provide link to file (PDF pop-out, Word download) +- Show green "Re-submit subscription" button +- POST: Navigate to confirm-resubmission page + +**confirm-resubmission (Page 5):** +- GET: Render confirmation page with summary table +- Query parameter: artefactId +- Display publication metadata in summary table +- Show Confirm button and Cancel link +- POST: Trigger resubmission, redirect to success page +- Cancel: Redirect to blob-explorer-locations + +**resubmission-success (Page 6):** +- GET: Render success page +- Display green success banner +- Provide link back to blob-explorer-locations +- Implement POST/Redirect/GET to prevent duplicate submissions + +### 5. Locales +Create en.ts and cy.ts with content for: +- Page titles +- Body text +- Button labels +- Link text +- Table column headings +- Error messages +- Success messages + +### 6. Accessibility Implementation +- Ensure all interactive elements support keyboard navigation +- Add appropriate ARIA roles for success banners and errors +- Implement aria-expanded and aria-controls for accordion +- Use semantic HTML for tables (, , ) +- Add proper heading hierarchy +- Test with screen readers +- Ensure Welsh language switching works correctly + +### 7. Styling +- Use GOV.UK Design System components +- Green button styling for "Re-submit subscription" and "Confirm" +- Success banner styling (green) +- Table styling for metadata and lists +- Accordion styling for raw JSON content +- Ensure responsive design + +### 8. Integration +- Add Blob Explorer tile to System Admin Dashboard +- Configure routing in apps/web/src/app.ts +- Ensure authentication middleware protects all pages +- Add authorization check for System Admin role + +### 9. Testing + +**Unit Tests (Vitest):** +- location-service.test.ts - Test location queries +- publication-service.test.ts - Test publication queries and metadata retrieval +- resubmission-service.test.ts - Test resubmission logic and notification sending + +**E2E Tests (Playwright):** +- Create single journey test: "System admin can browse and resubmit publication @nightly" + - Navigate from dashboard to locations + - Select location and view publications + - Select publication (test both JSON and flat file) + - View metadata and rendered content + - Trigger resubmission workflow + - Confirm submission and verify success + - Test Welsh translation at key points + - Test accessibility inline + - Test keyboard navigation + - Verify PRG pattern prevents duplicate submissions + +### 10. Documentation +- Update README if needed +- Document resubmission service API +- Add comments for complex blob storage logic + +## Dependencies +- @hmcts/postgres - Database access +- @hmcts/auth - Authentication/authorization +- GOV.UK Frontend - UI components +- Azure Blob Storage SDK (if not already present) +- Subscription notification service (existing or new) + +## Migration Requirements +- No new database tables expected (uses existing publication/location tables) +- If new tables needed, create Prisma schema in libs/blob-explorer/prisma/ + +## Risk Considerations +- Ensure resubmission doesn't cause duplicate notifications +- Handle large JSON files in accordion (pagination/truncation if needed) +- Blob storage access permissions for flat files +- Performance of location/publication queries with large datasets +- Audit logging for manual resubmissions + +## Definition of Done +- All 6 pages implemented with Welsh translations +- Database services retrieve correct data +- Blob storage integration works for JSON and flat files +- Manual resubmission triggers notifications to subscribers +- POST/Redirect/GET pattern prevents duplicate submissions +- All pages meet WCAG 2.2 AA standards +- E2E journey test passes (including Welsh and accessibility) +- Unit tests achieve >80% coverage on services +- Code reviewed and approved +- Module registered in web application diff --git a/docs/tickets/VIBE-310/specification.md b/docs/tickets/VIBE-310/specification.md new file mode 100644 index 00000000..f2d8724f --- /dev/null +++ b/docs/tickets/VIBE-310/specification.md @@ -0,0 +1,258 @@ +# VIBE-310: Blob explorer and manual re-submission trigger functionality + +## User Story +As a System Admin User, I want to access the Blob Explorer functionality in CaTH so that I can manually re-submit a publication. + +## Problem Statement +System admin users in CaTH access several system functionalities through the System Admin dashboard which allows them to perform administrative tasks. The dashboard acts as the main control panel for managing reference data, user accounts, media accounts, audit logs, and other administrative operations. This ticket covers the Blob explorer and manual re-submission trigger functionality. + +## Technical Specification +If a user clicks on re-submit for a specific publication, it should trigger subscription notification to all the subscribers for that publication. + +## Pages and User Journey + +### Page 1: System Admin Dashboard +- Shows Blob Explorer tile (button/tile component) +- Tile must be visible to System Admin users only +- Clicking the tile navigates to "Blob Explorer – Locations" + +**Content:** +- EN: Title/H1 — "System Admin dashboard" +- CY: Title/H1 — "Welsh placeholder" +- EN: Tile — "Blob explorer" +- CY: Tile — "Welsh placeholder" + +**Errors:** +- EN: "We could not load your system admin tools. Try again later." +- CY: "Welsh placeholder" + +**Navigation:** +- Browser back only + +--- + +### Page 2: Blob Explorer Locations +- Page title: "Blob Explorer Locations" +- Descriptive text: "Choose a location to see all publications associated with it." +- Table displaying all venues with columns: + - Location + - Number of publications per venue +- Selecting a location navigates to Blob Explorer Publications page + +**Content:** +- EN: Title/H1 — "Blob Explorer Locations" +- CY: Title/H1 — "Welsh placeholder" +- EN: Body text — "Choose a location to see all publications associated with it." +- CY: Body text — "Welsh placeholder" +- EN: Column headings — "Location", "Number of publications per venue" +- CY: Column headings — "Welsh placeholder", "Welsh placeholder" + +**Errors:** +- EN: "We could not load locations. Try again later." +- CY: "Welsh placeholder" + +**Navigation:** +- Back returns to System Admin dashboard + +--- + +### Page 3: Blob Explorer Publications +- Page title: "Blob Explorer Publications" +- Descriptive text: "Choose a publication from the list." +- Table displaying publications for selected location with columns: + - Artefact ID (clickable link) + - List Type + - Display From + - Display To +- Clicking Artefact ID opens either JSON file page or Flat file page depending on publication type + +**Content:** +- EN: Title/H1 — "Blob Explorer Publications" +- CY: Title/H1 — "Welsh placeholder" +- EN: Body text — "Choose a publication from the list." +- CY: Body text — "Welsh placeholder" +- EN: Column headings — "Artefact ID", "List type", "Display from", "Display to" +- CY: Column headings — "Welsh placeholder" × 4 + +**Errors:** +- EN: "We could not load publications for this location." +- CY: "Welsh placeholder" + +**Navigation:** +- Back returns to Blob Explorer Locations + +--- + +### Page 4A: Blob Explorer – JSON File +- Page title: "Blob Explorer – JSON file" +- Green "Re-submit subscription" button under page title +- Metadata section in a table with fields: + - Artefact ID + - Location ID + - Location Name + - Publication Type + - List Type + - Provenance + - Language + - Sensitivity + - Content Date + - Display From + - Display To +- "Link to rendered template" displayed below table (opens rendered publication) +- Closed accordion titled "View Raw JSON Content" (displays raw JSON when opened) + +**Content:** +- EN: Title/H1 — "Blob Explorer – JSON file" +- CY: Title/H1 — "Welsh placeholder" +- EN: Button — "Re-submit subscription" +- CY: Button — "Welsh placeholder" +- EN: Section heading — "Metadata" +- CY: Section heading — "Welsh placeholder" +- EN: Accordion title — "View Raw JSON Content" +- CY: Accordion title — "Welsh placeholder" +- EN: Link — "Link to rendered template" +- CY: Link — "Welsh placeholder" + +**Errors:** +- EN: "We could not load the JSON publication." +- CY: "Welsh placeholder" + +**Navigation:** +- Back returns to Blob Explorer Publications + +--- + +### Page 4B: Blob Explorer – Flat File +- Page title: "Blob Explorer – Flat file" +- Green "Re-submit subscription" button +- Metadata section (same fields as JSON file page) +- "Link to file" displayed below table: + - If PDF → opens in pop-out view + - If Word doc → triggers file download + +**Content:** +- EN: Title/H1 — "Blob Explorer – Flat file" +- CY: Title/H1 — "Welsh placeholder" +- EN: Button — "Re-submit subscription" +- CY: Button — "Welsh placeholder" +- EN: Section heading — "Metadata" +- CY: Section heading — "Welsh placeholder" +- EN: Link — "Link to file" +- CY: Link — "Welsh placeholder" + +**Errors:** +- EN: "We could not load the file publication." +- CY: "Welsh placeholder" + +**Navigation:** +- Back returns to Blob Explorer Publications + +--- + +### Page 5: Confirm Subscription Re-submission +- Page title: "Confirm subscription re-submission" +- Summary table with fields: + - Location Name + - Publication Type + - List Type + - Provenance + - Language + - Sensitivity + - Content Date + - Display From + - Display To +- Green "Confirm" button +- "Cancel" link + +**Actions:** +- Confirm: Proceeds to submission confirmation page +- Cancel: Returns to Blob Explorer Locations + +**Content:** +- EN: Title/H1 — "Confirm subscription re-submission" +- CY: Title/H1 — "Welsh placeholder" +- EN: Button — "Confirm" +- CY: Button — "Welsh placeholder" +- EN: Link — "Cancel" +- CY: Link — "Welsh placeholder" + +**Errors:** +- EN: "We could not re-submit this publication. Try again later." +- CY: "Welsh placeholder" + +**Navigation:** +- Back returns to JSON/Flat File page + +--- + +### Page 6: Submission Re-submitted +- Green success banner with text "Submission re-submitted." +- Descriptive text: "What do you want to do next?" +- Link to "Blob explorer – Locations" + +**Content:** +- EN: Title/H1 — "Submission re-submitted" +- CY: Title/H1 — "Welsh placeholder" +- EN: Success banner text — "Submission re-submitted." +- CY: Success banner text — "Welsh placeholder" +- EN: Body text — "What do you want to do next?" +- CY: Body text — "Welsh placeholder" +- EN: Link — "Blob explorer – Locations" +- CY: Link — "Welsh placeholder" + +**Navigation:** +- Browser back returns to confirmation page but must not re-submit + +--- + +## Accessibility Requirements +- All screens must comply with WCAG 2.2 AA +- Buttons, links, tiles, tables, and accordions support keyboard navigation +- Focus order follows a logical sequence +- Success banners and errors use appropriate ARIA roles +- Accordion includes `aria-expanded` and `aria-controls` +- Tables use semantic `
`, ``, ``, and correct header scope +- Screen reader users can: + - Identify page titles + - Understand metadata tables + - Detect expanded/collapsed accordion states + - Receive announcements for errors and success messages +- Bilingual content rendered correctly when language switches + +## Test Scenarios + +### Dashboard +- Admin user sees Blob Explorer tile +- Clicking the tile → Blob Explorer Locations + +### Locations page +- Locations table loads with correct venue counts +- Clicking a location → Publications page + +### Publications page +- Publications list loads for selected venue +- Clicking an Artefact ID → loads correct JSON or Flat File page + +### JSON File page +- Metadata table displays all required fields +- "Link to rendered template" opens rendered publication +- Accordion displays raw JSON when opened +- Clicking Re-submit subscription → Confirmation page + +### Flat File page +- Metadata table displays all fields +- "Link to file" opens or downloads appropriate file +- Clicking Re-submit subscription → Confirmation page + +### Confirmation page +- Summary values match metadata +- Cancel → Locations page +- Confirm → Success page + +### Success page +- Success banner shown +- "Blob explorer – Locations" link returns to Locations + +### Accessibility +- All interactive elements reachable by keyboard +- Screen readers announce all metadata, errors, and success states From dc4a197cfbd7214dd10fe9a5c676a8be0fd7fbfd Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:44:34 +0000 Subject: [PATCH 2/8] VIBE-310 - Blob Explorer --- e2e-tests/tests/blob-explorer.spec.ts | 310 +++++++++++ .../tests/system-admin-dashboard.spec.ts | 2 +- libs/publication/src/index.ts | 16 +- .../src/repository/queries.test.ts | 481 +++++++++++++++++- libs/publication/src/repository/queries.ts | 131 +++++ .../src/repository/service.test.ts | 164 ++++++ libs/publication/src/repository/service.ts | 50 ++ libs/system-admin-pages/src/index.ts | 2 +- .../blob-explorer-confirm-resubmission/cy.ts | 7 + .../blob-explorer-confirm-resubmission/en.ts | 7 + .../index.njk | 115 +++++ .../index.test.ts | 175 +++++++ .../index.ts | 89 ++++ .../src/pages/blob-explorer-flat-file/cy.ts | 19 + .../src/pages/blob-explorer-flat-file/en.ts | 19 + .../pages/blob-explorer-flat-file/index.njk | 135 +++++ .../blob-explorer-flat-file/index.test.ts | 139 +++++ .../pages/blob-explorer-flat-file/index.ts | 74 +++ .../src/pages/blob-explorer-json-file/cy.ts | 20 + .../src/pages/blob-explorer-json-file/en.ts | 20 + .../pages/blob-explorer-json-file/index.njk | 143 ++++++ .../blob-explorer-json-file/index.test.ts | 169 ++++++ .../pages/blob-explorer-json-file/index.ts | 76 +++ .../src/pages/blob-explorer-locations/cy.ts | 8 + .../src/pages/blob-explorer-locations/en.ts | 8 + .../pages/blob-explorer-locations/index.njk | 43 ++ .../blob-explorer-locations/index.test.ts | 106 ++++ .../pages/blob-explorer-locations/index.ts | 43 ++ .../pages/blob-explorer-publications/cy.ts | 10 + .../pages/blob-explorer-publications/en.ts | 10 + .../blob-explorer-publications/index.njk | 48 ++ .../blob-explorer-publications/index.test.ts | 193 +++++++ .../pages/blob-explorer-publications/index.ts | 74 +++ .../blob-explorer-resubmission-success/cy.ts | 7 + .../blob-explorer-resubmission-success/en.ts | 7 + .../index.njk | 22 + .../index.test.ts | 49 ++ .../index.ts | 19 + .../src/pages/system-admin-dashboard/en.ts | 2 +- .../src/services/service.test.ts | 45 ++ .../src/services/service.ts | 4 + libs/system-admin-pages/src/types/session.ts | 7 + 42 files changed, 3062 insertions(+), 6 deletions(-) create mode 100644 e2e-tests/tests/blob-explorer.spec.ts create mode 100644 libs/publication/src/repository/service.test.ts create mode 100644 libs/publication/src/repository/service.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-locations/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-locations/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-locations/index.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-publications/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.ts create mode 100644 libs/system-admin-pages/src/services/service.test.ts create mode 100644 libs/system-admin-pages/src/services/service.ts create mode 100644 libs/system-admin-pages/src/types/session.ts diff --git a/e2e-tests/tests/blob-explorer.spec.ts b/e2e-tests/tests/blob-explorer.spec.ts new file mode 100644 index 00000000..4c8e08ac --- /dev/null +++ b/e2e-tests/tests/blob-explorer.spec.ts @@ -0,0 +1,310 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { loginWithSSO } from "../utils/sso-helpers.js"; +import { prisma } from "@hmcts/postgres"; +import fs from "node:fs/promises"; +import path from "node:path"; + +interface TestPublicationData { + artefactId: string; + locationId: number; + locationName: string; +} + +const testPublicationMap = new Map(); + +async function createTestPublication(): Promise { + // Get first location for test + const location = await prisma.location.findFirst(); + if (!location) { + throw new Error("No location found in database"); + } + + // Create test artefact (JSON publication) - let Prisma generate UUID + const artefact = await prisma.artefact.create({ + data: { + locationId: location.locationId.toString(), + provenance: "MANUAL_UPLOAD", + displayFrom: new Date("2024-01-01"), + displayTo: new Date("2024-12-31"), + language: "ENGLISH", + listTypeId: 1, // Family Daily Cause List + sensitivity: "PUBLIC", + contentDate: new Date("2024-06-01"), + isFlatFile: false, + }, + }); + + const artefactId = artefact.artefactId; + + // Create test JSON file in storage + const storageDir = path.join(process.cwd(), "storage", "temp", "uploads"); + await fs.mkdir(storageDir, { recursive: true }); + + const testJsonContent = { + document: { + publicationDate: "2024-06-01", + locationName: location.name, + language: "ENGLISH", + listType: "CIVIL_DAILY_CAUSE_LIST", + courtLists: [ + { + courtHouse: { courtHouseName: location.name }, + courtListings: [ + { case: { caseName: "Test Case 123" } }, + ], + }, + ], + }, + }; + + await fs.writeFile( + path.join(storageDir, `${artefactId}.json`), + JSON.stringify(testJsonContent, null, 2) + ); + + // Create a test user and subscription for resubmission test + const testUser = await prisma.user.create({ + data: { + email: "test-subscriber@example.com", + userProvenance: "SSO", + userProvenanceId: `test-${Date.now()}`, + role: "MEDIA", + }, + }); + + await prisma.subscription.create({ + data: { + userId: testUser.userId, + locationId: location.locationId, + }, + }); + + return { + artefactId, + locationId: location.locationId, + locationName: location.name, + }; +} + +async function deleteTestPublication(publicationData: TestPublicationData): Promise { + try { + if (!publicationData.artefactId) return; + + // Delete artefact + await prisma.artefact.delete({ + where: { artefactId: publicationData.artefactId }, + }); + + // Delete test file + const storageDir = path.join(process.cwd(), "storage", "temp", "uploads"); + try { + await fs.unlink(path.join(storageDir, `${publicationData.artefactId}.json`)); + } catch { + // File may not exist, ignore + } + } catch (error) { + console.log("Test publication cleanup:", error); + } +} + +test.describe("Blob Explorer", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/system-admin-dashboard"); + await loginWithSSO(page, process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD!); + await page.waitForURL("/system-admin-dashboard"); + }); + + test("System admin can browse and resubmit publication @nightly", async ({ page }, testInfo) => { + // Create test publication + const publicationData = await createTestPublication(); + testPublicationMap.set(testInfo.testId, publicationData); + + try { + // STEP 1: Navigate from dashboard to Blob Explorer Locations + await page.goto("/system-admin-dashboard"); + await expect(page.getByRole("heading", { name: /System Admin Dashboard/i })).toBeVisible(); + + // Find and click Blob Explorer tile + const blobExplorerTile = page.locator('a.admin-tile[href="/blob-explorer"]'); + await expect(blobExplorerTile).toBeVisible(); + await expect(blobExplorerTile).toContainText("Blob Explorer"); + await blobExplorerTile.click(); + + // STEP 2: Verify Blob Explorer Locations page + await expect(page).toHaveURL("/blob-explorer"); + await expect(page.getByRole("heading", { name: /Blob Explorer Locations/i, level: 1 })).toBeVisible(); + + // Test Welsh translation on locations page + await page.getByRole("link", { name: /Cymraeg/i }).click(); + await page.waitForURL(/lng=cy/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + // Switch back to English + await page.getByRole("link", { name: /English/i }).click(); + await page.waitForURL(/lng=en/); + + // Check accessibility on locations page + let accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Test keyboard navigation - tab to first location link + await page.keyboard.press("Tab"); + + // STEP 3: Select location and view publications + const locationTable = page.locator("table.govuk-table"); + await expect(locationTable).toBeVisible(); + + // Find row with our test location and click it + const locationLink = page.locator(`a[href*="/blob-explorer/publications?locationId=${publicationData.locationId}"]`).first(); + await expect(locationLink).toBeVisible(); + await locationLink.click(); + + // STEP 4: Verify Publications page + await expect(page).toHaveURL(new RegExp(`/blob-explorer/publications.*locationId=${publicationData.locationId}`)); + await expect(page.getByRole("heading", { name: /Blob Explorer Publications/i })).toBeVisible(); + + // Test Welsh on publications page + await page.getByRole("link", { name: /Cymraeg/i }).click(); + await page.waitForURL(/lng=cy/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: /English/i }).click(); + await page.waitForURL(/lng=en/); + + // Check accessibility on publications page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // STEP 5: Select JSON publication and view metadata + const publicationsTable = page.locator("table.govuk-table"); + await expect(publicationsTable).toBeVisible(); + + const artefactLink = page.locator(`a[href*="/blob-explorer/json-file?artefactId=${publicationData.artefactId}"]`).first(); + await expect(artefactLink).toBeVisible(); + await artefactLink.click(); + + // STEP 6: Verify JSON File page with metadata + await expect(page).toHaveURL(new RegExp(`/blob-explorer/json-file.*artefactId=${publicationData.artefactId}`)); + await expect(page.getByRole("heading", { name: /Blob Explorer – JSON file/i })).toBeVisible(); + + // Verify metadata table is visible + const metadataTable = page.locator("table.govuk-table").first(); + await expect(metadataTable).toBeVisible(); + + // Verify Re-submit subscription button + const resubmitButton = page.getByRole("button", { name: /Re-submit subscription/i }); + await expect(resubmitButton).toBeVisible(); + + // Test Welsh on JSON file page + await page.getByRole("link", { name: /Cymraeg/i }).click(); + await page.waitForURL(/lng=cy/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: /English/i }).click(); + await page.waitForURL(/lng=en/); + + // Check accessibility on JSON file page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // STEP 7: Test accordion with raw JSON content + const accordion = page.locator("details.govuk-details"); + await expect(accordion).toBeVisible(); + + // Open accordion + const accordionSummary = accordion.locator("summary"); + await accordionSummary.click(); + + // Verify JSON content is visible + const jsonContent = accordion.locator(".govuk-details__text"); + await expect(jsonContent).toBeVisible(); + + // STEP 8: Test viewing rendered template link + const renderedLink = page.getByRole("link", { name: /Link to rendered template/i }); + await expect(renderedLink).toBeVisible(); + + // STEP 9: Trigger resubmission workflow + await resubmitButton.click(); + + // STEP 10: Verify confirmation page + await expect(page).toHaveURL(new RegExp(`/blob-explorer/confirm-resubmission.*artefactId=${publicationData.artefactId}`)); + await expect(page.getByRole("heading", { name: /Confirm subscription re-submission/i })).toBeVisible(); + + // Verify summary table + const summaryTable = page.locator("table.govuk-table"); + await expect(summaryTable).toBeVisible(); + + // Test Welsh on confirmation page + await page.getByRole("link", { name: /Cymraeg/i }).click(); + await page.waitForURL(/lng=cy/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: /English/i }).click(); + await page.waitForURL(/lng=en/); + + // Check accessibility on confirmation page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Test Cancel link (should return to locations) + const cancelLink = page.getByRole("link", { name: /Cancel/i }); + await expect(cancelLink).toBeVisible(); + + // Test keyboard navigation to Cancel link + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + + // Don't click Cancel, proceed with Confirm instead + + // STEP 11: Confirm submission + const confirmButton = page.getByRole("button", { name: /Confirm/i }); + await expect(confirmButton).toBeVisible(); + await confirmButton.click(); + + // STEP 12: Verify success page + await expect(page).toHaveURL("/blob-explorer/resubmission-success"); + await expect(page.getByRole("heading", { name: /Submission re-submitted/i })).toBeVisible(); + + // Verify success panel/banner + const successPanel = page.locator(".govuk-panel"); + await expect(successPanel).toBeVisible(); + + // Test Welsh on success page + await page.getByRole("link", { name: /Cymraeg/i }).click(); + await page.waitForURL(/lng=cy/); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: /English/i }).click(); + await page.waitForURL(/lng=en/); + + // Check accessibility on success page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // STEP 13: Test POST/Redirect/GET pattern - browser back should not re-submit + await page.goBack(); + await expect(page).toHaveURL("/blob-explorer/resubmission-success"); + // Should still be on success page, not trigger another submission + + // STEP 14: Navigate back to locations + const locationsLink = page.getByRole("link", { name: /Blob explorer – Locations/i }); + await expect(locationsLink).toBeVisible(); + await locationsLink.click(); + await expect(page).toHaveURL("/blob-explorer"); + + } finally { + // Cleanup test publication + const pubData = testPublicationMap.get(testInfo.testId); + if (pubData) { + await deleteTestPublication(pubData); + testPublicationMap.delete(testInfo.testId); + } + } + }); +}); diff --git a/e2e-tests/tests/system-admin-dashboard.spec.ts b/e2e-tests/tests/system-admin-dashboard.spec.ts index c43954ef..6f485c82 100644 --- a/e2e-tests/tests/system-admin-dashboard.spec.ts +++ b/e2e-tests/tests/system-admin-dashboard.spec.ts @@ -32,7 +32,7 @@ test.describe("System Admin Dashboard", () => { { title: "Delete Court", href: "/delete-court" }, { title: "Manage Third-Party Users", href: "/third-party-users" }, { title: "User Management", href: "/user-management" }, - { title: "Blob Explorer", href: "/blob-explorer" }, + { title: "Blob Explorer", href: "/blob-explorer-locations" }, { title: "Bulk Create Media Accounts", href: "/bulk-media-accounts" }, { title: "Audit Log Viewer", href: "/audit-log-viewer" }, { title: "Manage Location Metadata", href: "/location-metadata" } diff --git a/libs/publication/src/index.ts b/libs/publication/src/index.ts index 2e2138e2..87680736 100644 --- a/libs/publication/src/index.ts +++ b/libs/publication/src/index.ts @@ -3,6 +3,20 @@ export { Language } from "./language.js"; export { mockPublications, type Publication } from "./mock-publications.js"; export { PROVENANCE_LABELS, Provenance } from "./provenance.js"; export type { Artefact } from "./repository/model.js"; -export { createArtefact, deleteArtefacts, getArtefactsByIds, getArtefactsByLocation } from "./repository/queries.js"; +export { + type ArtefactMetadata, + type ArtefactSummary, + createArtefact, + deleteArtefacts, + getArtefactListTypeId, + getArtefactMetadata, + getArtefactSummariesByLocation, + getArtefactsByIds, + getArtefactsByLocation, + getArtefactType, + getLocationsWithPublicationCount, + type LocationWithPublicationCount +} from "./repository/queries.js"; +export { getFlatFileUrl, getJsonContent, getRenderedTemplateUrl } from "./repository/service.js"; export { Sensitivity } from "./sensitivity.js"; export { type ValidationResult, validateJson } from "./validation/json-validator.js"; diff --git a/libs/publication/src/repository/queries.test.ts b/libs/publication/src/repository/queries.test.ts index 2e2e04c2..ca23cd26 100644 --- a/libs/publication/src/repository/queries.test.ts +++ b/libs/publication/src/repository/queries.test.ts @@ -1,5 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createArtefact, deleteArtefacts, getArtefactsByIds, getArtefactsByLocation } from "./queries.js"; +import { + createArtefact, + deleteArtefacts, + getArtefactListTypeId, + getArtefactMetadata, + getArtefactSummariesByLocation, + getArtefactsByIds, + getArtefactsByLocation, + getArtefactType, + getLocationsWithPublicationCount +} from "./queries.js"; vi.mock("@hmcts/postgres", () => ({ prisma: { @@ -8,11 +18,37 @@ vi.mock("@hmcts/postgres", () => ({ create: vi.fn(), update: vi.fn(), findMany: vi.fn(), + findUnique: vi.fn(), deleteMany: vi.fn() - } + }, + $queryRaw: vi.fn() } })); +vi.mock("@hmcts/list-types-common", () => ({ + mockListTypes: [ + { + id: 1, + name: "CIVIL_DAILY_CAUSE_LIST", + englishFriendlyName: "Civil Daily Cause List", + welshFriendlyName: "Rhestr Achosion Dyddiol Sifil", + provenance: "CFT_IDAM" + }, + { + id: 2, + name: "FAMILY_DAILY_CAUSE_LIST", + englishFriendlyName: "Family Daily Cause List", + welshFriendlyName: "Rhestr Achosion Dyddiol Teulu", + provenance: "CFT_IDAM" + } + ] +})); + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; import { prisma } from "@hmcts/postgres"; describe("createArtefact", () => { @@ -553,3 +589,444 @@ describe("deleteArtefacts", () => { }); }); }); + +describe("getArtefactSummariesByLocation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return artefact summaries for a given location", async () => { + const mockArtefacts = [ + { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 1, + contentDate: new Date("2025-10-25"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + }, + { + artefactId: "550e8400-e29b-41d4-a716-446655440001", + locationId: "123", + listTypeId: 2, + contentDate: new Date("2025-10-24"), + sensitivity: "PRIVATE", + language: "WELSH", + displayFrom: new Date("2025-10-22T09:00:00.000Z"), + displayTo: new Date("2025-10-28T17:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: true, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } + ] as any; + + vi.mocked(prisma.artefact.findMany).mockResolvedValue(mockArtefacts); + + const result = await getArtefactSummariesByLocation("123"); + + expect(prisma.artefact.findMany).toHaveBeenCalledWith({ + where: { locationId: "123" }, + orderBy: { displayFrom: "desc" } + }); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + artefactId: "550e8400-e29b-41d4-a716-446655440000", + listType: "Civil Daily Cause List", + displayFrom: "2025-10-20T10:00:00.000Z", + displayTo: "2025-10-30T16:00:00.000Z" + }); + expect(result[1]).toEqual({ + artefactId: "550e8400-e29b-41d4-a716-446655440001", + listType: "Family Daily Cause List", + displayFrom: "2025-10-22T09:00:00.000Z", + displayTo: "2025-10-28T17:00:00.000Z" + }); + }); + + it("should return Unknown for list type when not found", async () => { + const mockArtefacts = [ + { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 999, + contentDate: new Date("2025-10-25"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } + ] as any; + + vi.mocked(prisma.artefact.findMany).mockResolvedValue(mockArtefacts); + + const result = await getArtefactSummariesByLocation("123"); + + expect(result[0].listType).toBe("Unknown"); + }); + + it("should return empty array when no artefacts found", async () => { + vi.mocked(prisma.artefact.findMany).mockResolvedValue([]); + + const result = await getArtefactSummariesByLocation("999"); + + expect(result).toEqual([]); + }); +}); + +describe("getArtefactMetadata", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return artefact metadata with location and list type details", async () => { + const mockArtefact = { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 1, + contentDate: new Date("2025-10-25T00:00:00.000Z"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + + const mockLocation = { + locationId: 123, + name: "Test Court", + welshName: "Llys Prawf", + regions: [1], + subJurisdictions: [1] + }; + + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(mockArtefact); + vi.mocked(getLocationById).mockResolvedValue(mockLocation); + + const result = await getArtefactMetadata("550e8400-e29b-41d4-a716-446655440000"); + + expect(prisma.artefact.findUnique).toHaveBeenCalledWith({ + where: { artefactId: "550e8400-e29b-41d4-a716-446655440000" } + }); + expect(getLocationById).toHaveBeenCalledWith(123); + expect(result).toEqual({ + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + locationName: "Test Court", + publicationType: "JSON", + listType: "Civil Daily Cause List", + provenance: "Manual Upload", + language: "ENGLISH", + sensitivity: "PUBLIC", + contentDate: "2025-10-25T00:00:00.000Z", + displayFrom: "2025-10-20T10:00:00.000Z", + displayTo: "2025-10-30T16:00:00.000Z" + }); + }); + + it("should return Flat File for publication type when isFlatFile is true", async () => { + const mockArtefact = { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 1, + contentDate: new Date("2025-10-25T00:00:00.000Z"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: true, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + + const mockLocation = { + locationId: 123, + name: "Test Court", + welshName: "Llys Prawf", + regions: [1], + subJurisdictions: [1] + }; + + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(mockArtefact); + vi.mocked(getLocationById).mockResolvedValue(mockLocation); + + const result = await getArtefactMetadata("550e8400-e29b-41d4-a716-446655440000"); + + expect(result?.publicationType).toBe("Flat File"); + }); + + it("should return Unknown for location name when location not found", async () => { + const mockArtefact = { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "999", + listTypeId: 1, + contentDate: new Date("2025-10-25T00:00:00.000Z"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(mockArtefact); + vi.mocked(getLocationById).mockResolvedValue(undefined); + + const result = await getArtefactMetadata("550e8400-e29b-41d4-a716-446655440000"); + + expect(result?.locationName).toBe("Unknown"); + }); + + it("should return Unknown for list type when not found", async () => { + const mockArtefact = { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 999, + contentDate: new Date("2025-10-25T00:00:00.000Z"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + + const mockLocation = { + locationId: 123, + name: "Test Court", + welshName: "Llys Prawf", + regions: [1], + subJurisdictions: [1] + }; + + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(mockArtefact); + vi.mocked(getLocationById).mockResolvedValue(mockLocation); + + const result = await getArtefactMetadata("550e8400-e29b-41d4-a716-446655440000"); + + expect(result?.listType).toBe("Unknown"); + }); + + it("should return provenance label from PROVENANCE_LABELS", async () => { + const mockArtefact = { + artefactId: "550e8400-e29b-41d4-a716-446655440000", + locationId: "123", + listTypeId: 1, + contentDate: new Date("2025-10-25T00:00:00.000Z"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-10-20T10:00:00.000Z"), + displayTo: new Date("2025-10-30T16:00:00.000Z"), + lastReceivedDate: new Date(), + isFlatFile: false, + provenance: "XHIBIT", + supersededCount: 0, + noMatch: false + } as any; + + const mockLocation = { + locationId: 123, + name: "Test Court", + welshName: "Llys Prawf", + regions: [1], + subJurisdictions: [1] + }; + + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(mockArtefact); + vi.mocked(getLocationById).mockResolvedValue(mockLocation); + + const result = await getArtefactMetadata("550e8400-e29b-41d4-a716-446655440000"); + + expect(result?.provenance).toBe("XHIBIT"); + }); + + it("should return null when artefact not found", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(null); + + const result = await getArtefactMetadata("non-existent-id"); + + expect(result).toBeNull(); + expect(getLocationById).not.toHaveBeenCalled(); + }); +}); + +describe("getArtefactType", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return flat-file when isFlatFile is true", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue({ + isFlatFile: true + } as any); + + const result = await getArtefactType("550e8400-e29b-41d4-a716-446655440000"); + + expect(prisma.artefact.findUnique).toHaveBeenCalledWith({ + where: { artefactId: "550e8400-e29b-41d4-a716-446655440000" }, + select: { isFlatFile: true } + }); + expect(result).toBe("flat-file"); + }); + + it("should return json when isFlatFile is false", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue({ + isFlatFile: false + } as any); + + const result = await getArtefactType("550e8400-e29b-41d4-a716-446655440001"); + + expect(result).toBe("json"); + }); + + it("should return null when artefact not found", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(null); + + const result = await getArtefactType("non-existent-id"); + + expect(result).toBeNull(); + }); +}); + +describe("getLocationsWithPublicationCount", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return locations with publication counts", async () => { + const mockResult = [ + { + location_id: 1, + location_name: "Manchester Crown Court", + publication_count: BigInt(5) + }, + { + location_id: 2, + location_name: "Birmingham Crown Court", + publication_count: BigInt(3) + } + ]; + + vi.mocked(prisma.$queryRaw).mockResolvedValue(mockResult); + + const result = await getLocationsWithPublicationCount(); + + expect(result).toEqual([ + { + locationId: "1", + locationName: "Manchester Crown Court", + publicationCount: 5 + }, + { + locationId: "2", + locationName: "Birmingham Crown Court", + publicationCount: 3 + } + ]); + }); + + it("should return empty array when no locations have publications", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([]); + + const result = await getLocationsWithPublicationCount(); + + expect(result).toEqual([]); + }); + + it("should handle bigint conversion correctly", async () => { + const mockResult = [ + { + location_id: 123, + location_name: "Test Court", + publication_count: BigInt(999) + } + ]; + + vi.mocked(prisma.$queryRaw).mockResolvedValue(mockResult); + + const result = await getLocationsWithPublicationCount(); + + expect(result[0].publicationCount).toBe(999); + expect(typeof result[0].publicationCount).toBe("number"); + }); + + it("should convert location_id to string", async () => { + const mockResult = [ + { + location_id: 456, + location_name: "Test Location", + publication_count: BigInt(10) + } + ]; + + vi.mocked(prisma.$queryRaw).mockResolvedValue(mockResult); + + const result = await getLocationsWithPublicationCount(); + + expect(result[0].locationId).toBe("456"); + expect(typeof result[0].locationId).toBe("string"); + }); +}); + +describe("getArtefactListTypeId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return listTypeId for valid artefact", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue({ + listTypeId: 1 + } as any); + + const result = await getArtefactListTypeId("test-artefact-id"); + + expect(result).toBe(1); + expect(prisma.artefact.findUnique).toHaveBeenCalledWith({ + where: { artefactId: "test-artefact-id" }, + select: { listTypeId: true } + }); + }); + + it("should return null when artefact not found", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue(null); + + const result = await getArtefactListTypeId("non-existent-id"); + + expect(result).toBeNull(); + }); + + it("should return correct listTypeId for different artefacts", async () => { + vi.mocked(prisma.artefact.findUnique).mockResolvedValue({ + listTypeId: 5 + } as any); + + const result = await getArtefactListTypeId("another-artefact"); + + expect(result).toBe(5); + }); +}); diff --git a/libs/publication/src/repository/queries.ts b/libs/publication/src/repository/queries.ts index 929118e9..b096010f 100644 --- a/libs/publication/src/repository/queries.ts +++ b/libs/publication/src/repository/queries.ts @@ -1,6 +1,36 @@ +import { mockListTypes } from "@hmcts/list-types-common"; +import { getLocationById } from "@hmcts/location"; import { prisma } from "@hmcts/postgres"; +import { PROVENANCE_LABELS } from "../provenance.js"; import type { Artefact } from "./model.js"; +export interface ArtefactSummary { + artefactId: string; + listType: string; + displayFrom: string; + displayTo: string; +} + +export interface ArtefactMetadata { + artefactId: string; + locationId: string; + locationName: string; + publicationType: string; + listType: string; + provenance: string; + language: string; + sensitivity: string; + contentDate: string; + displayFrom: string; + displayTo: string; +} + +export interface LocationWithPublicationCount { + locationId: string; + locationName: string; + publicationCount: number; +} + export async function createArtefact(data: Artefact): Promise { // Check if artefact already exists with same location, list type, content date, and language const existing = await prisma.artefact.findFirst({ @@ -113,3 +143,104 @@ export async function deleteArtefacts(artefactIds: string[]): Promise { } }); } + +export async function getArtefactSummariesByLocation(locationId: string): Promise { + const artefacts = await prisma.artefact.findMany({ + where: { + locationId + }, + orderBy: { + displayFrom: "desc" + } + }); + + return artefacts.map((artefact) => { + const listType = mockListTypes.find((lt) => lt.id === artefact.listTypeId); + + return { + artefactId: artefact.artefactId, + listType: listType?.englishFriendlyName || "Unknown", + displayFrom: artefact.displayFrom.toISOString(), + displayTo: artefact.displayTo.toISOString() + }; + }); +} + +export async function getArtefactMetadata(artefactId: string): Promise { + const artefact = await prisma.artefact.findUnique({ + where: { + artefactId + } + }); + + if (!artefact) { + return null; + } + + const listType = mockListTypes.find((lt) => lt.id === artefact.listTypeId); + const location = await getLocationById(Number.parseInt(artefact.locationId)); + + return { + artefactId: artefact.artefactId, + locationId: artefact.locationId, + locationName: location?.name || "Unknown", + publicationType: artefact.isFlatFile ? "Flat File" : "JSON", + listType: listType?.englishFriendlyName || "Unknown", + provenance: PROVENANCE_LABELS[artefact.provenance] || artefact.provenance, + language: artefact.language, + sensitivity: artefact.sensitivity, + contentDate: artefact.contentDate.toISOString(), + displayFrom: artefact.displayFrom.toISOString(), + displayTo: artefact.displayTo.toISOString() + }; +} + +export async function getArtefactType(artefactId: string): Promise<"json" | "flat-file" | null> { + const artefact = await prisma.artefact.findUnique({ + where: { + artefactId + }, + select: { + isFlatFile: true + } + }); + + if (!artefact) { + return null; + } + + return artefact.isFlatFile ? "flat-file" : "json"; +} + +export async function getLocationsWithPublicationCount(): Promise { + const result = await prisma.$queryRaw>` + SELECT + l.location_id, + l.name as location_name, + COUNT(a.artefact_id) as publication_count + FROM location l + LEFT JOIN artefact a ON CAST(l.location_id AS VARCHAR) = a.location_id + GROUP BY l.location_id, l.name + HAVING COUNT(a.artefact_id) > 0 + ORDER BY l.name ASC + `; + + return result.map((row) => ({ + locationId: String(row.location_id), + locationName: row.location_name, + publicationCount: Number(row.publication_count) + })); +} + +export async function getArtefactListTypeId(artefactId: string): Promise { + const artefact = await prisma.artefact.findUnique({ + where: { + artefactId + }, + select: { + listTypeId: true + } + }); + + return artefact?.listTypeId ?? null; +} diff --git a/libs/publication/src/repository/service.test.ts b/libs/publication/src/repository/service.test.ts new file mode 100644 index 00000000..d2f73280 --- /dev/null +++ b/libs/publication/src/repository/service.test.ts @@ -0,0 +1,164 @@ +import fs from "node:fs/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as queries from "./queries.js"; +import { getFlatFileUrl, getJsonContent, getRenderedTemplateUrl } from "./service.js"; + +vi.mock("./queries.js", () => ({ + getArtefactListTypeId: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + mockListTypes: [ + { + id: 1, + name: "CIVIL_DAILY_CAUSE_LIST", + englishFriendlyName: "Civil Daily Cause List", + welshFriendlyName: "Rhestr Achosion Dyddiol Sifil", + provenance: "CFT_IDAM", + urlPath: "civil-daily-cause-list" + }, + { + id: 2, + name: "FAMILY_DAILY_CAUSE_LIST", + englishFriendlyName: "Family Daily Cause List", + welshFriendlyName: "Rhestr Achosion Dyddiol Teulu", + provenance: "CFT_IDAM", + urlPath: "family-daily-cause-list" + }, + { + id: 3, + name: "NO_URL_LIST", + englishFriendlyName: "No URL List", + welshFriendlyName: "Dim Rhestr URL", + provenance: "CFT_IDAM" + } + ] +})); + +vi.mock("node:fs/promises"); + +describe("Publication Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getJsonContent", () => { + it("should return parsed JSON content from file", async () => { + const mockContent = { + document: { + publicationDate: "2024-06-01", + locationName: "Test Court" + } + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockContent)); + + const result = await getJsonContent("test-artefact-id"); + + expect(result).toEqual(mockContent); + expect(fs.readFile).toHaveBeenCalledWith(expect.stringContaining("test-artefact-id.json"), "utf-8"); + }); + + it("should return null when file does not exist", async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error("ENOENT: no such file or directory")); + + const result = await getJsonContent("non-existent-id"); + + expect(result).toBeNull(); + }); + + it("should return null when JSON is invalid", async () => { + vi.mocked(fs.readFile).mockResolvedValue("invalid json{"); + + const result = await getJsonContent("invalid-json-id"); + + expect(result).toBeNull(); + }); + }); + + describe("getRenderedTemplateUrl", () => { + it("should return rendered template URL for valid artefact with urlPath", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(1); + + const result = await getRenderedTemplateUrl("test-artefact-id"); + + expect(result).toBe("/civil-daily-cause-list?artefactId=test-artefact-id"); + expect(queries.getArtefactListTypeId).toHaveBeenCalledWith("test-artefact-id"); + }); + + it("should return null when artefact not found", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(null); + + const result = await getRenderedTemplateUrl("non-existent-id"); + + expect(result).toBeNull(); + }); + + it("should return null when list type not found", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(999); + + const result = await getRenderedTemplateUrl("test-artefact-id"); + + expect(result).toBeNull(); + }); + + it("should return null when list type has no urlPath", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(3); + + const result = await getRenderedTemplateUrl("test-artefact-id"); + + expect(result).toBeNull(); + }); + + it("should work with different list types", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(2); + + const result = await getRenderedTemplateUrl("test-artefact-id-2"); + + expect(result).toBe("/family-daily-cause-list?artefactId=test-artefact-id-2"); + }); + }); + + describe("getFlatFileUrl", () => { + it("should return file URL when flat file exists", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "other-file.txt"] as any); + + const result = await getFlatFileUrl("test-artefact-id"); + + expect(result).toBe("/files/test-artefact-id.pdf"); + expect(fs.readdir).toHaveBeenCalledWith(expect.stringContaining("storage/temp/uploads")); + }); + + it("should return null when no matching file found", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["other-file.pdf", "another-file.txt"] as any); + + const result = await getFlatFileUrl("test-artefact-id"); + + expect(result).toBeNull(); + }); + + it("should return null when directory read fails", async () => { + vi.mocked(fs.readdir).mockRejectedValue(new Error("ENOENT: no such directory")); + + const result = await getFlatFileUrl("test-artefact-id"); + + expect(result).toBeNull(); + }); + + it("should match file with any extension", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.docx", "other-file.pdf"] as any); + + const result = await getFlatFileUrl("test-artefact-id"); + + expect(result).toBe("/files/test-artefact-id.docx"); + }); + + it("should return first matching file when multiple exist", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "test-artefact-id.docx"] as any); + + const result = await getFlatFileUrl("test-artefact-id"); + + expect(result).toBe("/files/test-artefact-id.pdf"); + }); + }); +}); diff --git a/libs/publication/src/repository/service.ts b/libs/publication/src/repository/service.ts new file mode 100644 index 00000000..db0e64dc --- /dev/null +++ b/libs/publication/src/repository/service.ts @@ -0,0 +1,50 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { mockListTypes } from "@hmcts/list-types-common"; +import { getArtefactListTypeId } from "./queries.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MONOREPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); +const TEMP_STORAGE_BASE = path.join(MONOREPO_ROOT, "storage", "temp", "uploads"); + +export async function getJsonContent(artefactId: string): Promise { + try { + const filePath = path.join(TEMP_STORAGE_BASE, `${artefactId}.json`); + const content = await fs.readFile(filePath, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +} + +export async function getRenderedTemplateUrl(artefactId: string): Promise { + const listTypeId = await getArtefactListTypeId(artefactId); + + if (!listTypeId) { + return null; + } + + const listType = mockListTypes.find((lt) => lt.id === listTypeId); + if (!listType?.urlPath) { + return null; + } + + return `/${listType.urlPath}?artefactId=${artefactId}`; +} + +export async function getFlatFileUrl(artefactId: string): Promise { + try { + const files = await fs.readdir(TEMP_STORAGE_BASE); + const fileMatch = files.find((file) => file.startsWith(`${artefactId}.`)); + + if (!fileMatch) { + return null; + } + + return `/files/${fileMatch}`; + } catch { + return null; + } +} diff --git a/libs/system-admin-pages/src/index.ts b/libs/system-admin-pages/src/index.ts index 19cdc2d2..e144d375 100644 --- a/libs/system-admin-pages/src/index.ts +++ b/libs/system-admin-pages/src/index.ts @@ -1 +1 @@ -// Module exports will be added here as needed +export * from "./services/service.js"; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts new file mode 100644 index 00000000..d3f9e934 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + confirmTitle: "Confirm subscription re-submission", + confirmButton: "Confirm", + confirmCancelLink: "Cancel", + confirmError: "We could not re-submit this publication. Try again later.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts new file mode 100644 index 00000000..0ea909f2 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts @@ -0,0 +1,7 @@ +export const en = { + confirmTitle: "Confirm subscription re-submission", + confirmButton: "Confirm", + confirmCancelLink: "Cancel", + confirmError: "We could not re-submit this publication. Try again later.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk new file mode 100644 index 00000000..0c6265db --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk @@ -0,0 +1,115 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + +{% block content %} +
+
+ +

{{ confirmTitle }}

+ + {% if error %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: error + } + ] + }) }} + {% else %} + + {{ govukTable({ + rows: [ + [ + { + text: metadataLocationName + }, + { + text: metadata.locationName + } + ], + [ + { + text: metadataPublicationType + }, + { + text: metadata.publicationType + } + ], + [ + { + text: metadataListType + }, + { + text: metadata.listType + } + ], + [ + { + text: metadataProvenance + }, + { + text: metadata.provenance + } + ], + [ + { + text: metadataLanguage + }, + { + text: metadata.language + } + ], + [ + { + text: metadataSensitivity + }, + { + text: metadata.sensitivity + } + ], + [ + { + text: metadataContentDate + }, + { + text: formatDateTime(metadata.contentDate) + } + ], + [ + { + text: metadataDisplayFrom + }, + { + text: formatDateTime(metadata.displayFrom) + } + ], + [ + { + text: metadataDisplayTo + }, + { + text: formatDateTime(metadata.displayTo) + } + ] + ] + }) }} + +
+ {{ govukButton({ + text: confirmButton, + classes: "govuk-button" + }) }} + + +

+ {{ confirmCancelLink }} +

+ + {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.test.ts new file mode 100644 index 00000000..47a7c73e --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.test.ts @@ -0,0 +1,175 @@ +import * as publication from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as notificationService from "../../services/service.js"; + +vi.mock("@hmcts/publication", () => ({ + getArtefactMetadata: vi.fn() +})); + +vi.mock("../../services/service.js", () => ({ + sendPublicationNotifications: vi.fn() +})); + +const { GET, POST } = await import("./index.js"); + +describe("blob-explorer-confirm-resubmission page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: { artefactId: "abc-123" }, + session: {} as any + }; + + mockResponse = { + render: vi.fn(), + redirect: vi.fn() + }; + }); + + describe("GET", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should render the blob-explorer-confirm-resubmission page with English content", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(publication.getArtefactMetadata).toHaveBeenCalledWith("abc-123"); + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + metadata: mockMetadata, + artefactId: "abc-123", + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-confirm-resubmission page with Welsh content when lng=cy", async () => { + mockRequest.query = { artefactId: "abc-123", lng: "cy" }; + + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + + it("should render error when metadata is null", async () => { + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + + it("should render error when service fails", async () => { + vi.mocked(publication.getArtefactMetadata).mockRejectedValue(new Error("Database error")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + }); + + describe("POST", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should send notifications, clear session and redirect to success page", async () => { + mockRequest.session.resubmissionArtefactId = "abc-123"; + + vi.mocked(notificationService.sendPublicationNotifications).mockResolvedValue(undefined); + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(notificationService.sendPublicationNotifications).toHaveBeenCalledWith("abc-123"); + expect(mockRequest.session.resubmissionArtefactId).toBeUndefined(); + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-resubmission-success"); + }); + + it("should render error when notification service fails", async () => { + vi.mocked(notificationService.sendPublicationNotifications).mockRejectedValue(new Error("Notification error")); + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + error: expect.any(String), + artefactId: "abc-123" + }) + ); + }); + + it("should render error in Welsh when notification fails with lng=cy", async () => { + mockRequest.query = { artefactId: "abc-123", lng: "cy" }; + + vi.mocked(notificationService.sendPublicationNotifications).mockRejectedValue(new Error("Notification error")); + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-confirm-resubmission/index", + expect.objectContaining({ + error: expect.any(String), + locale: "cy" + }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts new file mode 100644 index 00000000..c9a7f0c9 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts @@ -0,0 +1,89 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { getArtefactMetadata } from "@hmcts/publication"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import "../../types/session.js"; +import { sendPublicationNotifications } from "../../services/service.js"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + try { + const metadata = await getArtefactMetadata(artefactId); + + if (!metadata) { + return res.render("blob-explorer-confirm-resubmission/index", { + ...t, + error: t.confirmError, + locale + }); + } + + res.render("blob-explorer-confirm-resubmission/index", { + ...t, + metadata, + artefactId, + formatDateTime, + locale + }); + } catch (error) { + console.error("Error loading confirmation:", error); + res.render("blob-explorer-confirm-resubmission/index", { + ...t, + error: t.confirmError, + locale + }); + } +}; + +const postHandler = async (req: Request, res: Response) => { + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + try { + await sendPublicationNotifications(artefactId); + + // Clear session data + delete req.session.resubmissionArtefactId; + + return res.redirect("/blob-explorer-resubmission-success"); + } catch (error) { + console.error("Error triggering resubmission:", error); + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + + res.render("blob-explorer-confirm-resubmission/index", { + ...t, + error: t.confirmError, + artefactId, + locale + }); + } +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts new file mode 100644 index 00000000..2440623e --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts @@ -0,0 +1,19 @@ +export const cy = { + flatFileTitle: "Blob Explorer – Flat file", + flatFileResubmitButton: "Re-submit subscription", + flatFileMetadataHeading: "Metadata", + flatFileLinkToFile: "Link to file", + flatFileError: "We could not load the file publication.", + metadataArtefactId: "Artefact ID", + metadataLocationId: "Location ID", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts new file mode 100644 index 00000000..4940a63d --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts @@ -0,0 +1,19 @@ +export const en = { + flatFileTitle: "Blob Explorer – Flat file", + flatFileResubmitButton: "Re-submit subscription", + flatFileMetadataHeading: "Metadata", + flatFileLinkToFile: "Link to file", + flatFileError: "We could not load the file publication.", + metadataArtefactId: "Artefact ID", + metadataLocationId: "Location ID", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.njk new file mode 100644 index 00000000..08444060 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.njk @@ -0,0 +1,135 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + +{% block content %} +
+
+ +

{{ flatFileTitle }}

+ + {% if error %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: error + } + ] + }) }} + {% else %} + +
+ {{ govukButton({ + text: flatFileResubmitButton, + classes: "govuk-button" + }) }} + + +

{{ flatFileMetadataHeading }}

+ + {{ govukTable({ + rows: [ + [ + { + text: metadataArtefactId + }, + { + text: metadata.artefactId + } + ], + [ + { + text: metadataLocationId + }, + { + text: metadata.locationId + } + ], + [ + { + text: metadataLocationName + }, + { + text: metadata.locationName + } + ], + [ + { + text: metadataPublicationType + }, + { + text: metadata.publicationType + } + ], + [ + { + text: metadataListType + }, + { + text: metadata.listType + } + ], + [ + { + text: metadataProvenance + }, + { + text: metadata.provenance + } + ], + [ + { + text: metadataLanguage + }, + { + text: metadata.language + } + ], + [ + { + text: metadataSensitivity + }, + { + text: metadata.sensitivity + } + ], + [ + { + text: metadataContentDate + }, + { + text: formatDateTime(metadata.contentDate) + } + ], + [ + { + text: metadataDisplayFrom + }, + { + text: formatDateTime(metadata.displayFrom) + } + ], + [ + { + text: metadataDisplayTo + }, + { + text: formatDateTime(metadata.displayTo) + } + ] + ] + }) }} + + {% if flatFileUrl %} +

+ {{ flatFileLinkToFile }} +

+ {% endif %} + + {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.test.ts new file mode 100644 index 00000000..76a78299 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.test.ts @@ -0,0 +1,139 @@ +import * as publication from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + getArtefactMetadata: vi.fn(), + getFlatFileUrl: vi.fn() +})); + +const { GET, POST } = await import("./index.js"); + +describe("blob-explorer-flat-file page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: { artefactId: "abc-123" }, + session: {} as any + }; + + mockResponse = { + render: vi.fn(), + redirect: vi.fn() + }; + }); + + describe("GET", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should render the blob-explorer-flat-file page with English content", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + const mockUrl = "https://example.com/flat-file.pdf"; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getFlatFileUrl).mockResolvedValue(mockUrl); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(publication.getArtefactMetadata).toHaveBeenCalledWith("abc-123"); + expect(publication.getFlatFileUrl).toHaveBeenCalledWith("abc-123"); + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-flat-file/index", + expect.objectContaining({ + metadata: mockMetadata, + flatFileUrl: mockUrl, + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-flat-file page with Welsh content when lng=cy", async () => { + mockRequest.query = { artefactId: "abc-123", lng: "cy" }; + + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getFlatFileUrl).mockResolvedValue("https://example.com/file.pdf"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-flat-file/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + + it("should render error when metadata is null", async () => { + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(null); + vi.mocked(publication.getFlatFileUrl).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-flat-file/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + + it("should render error when service fails", async () => { + vi.mocked(publication.getArtefactMetadata).mockRejectedValue(new Error("Database error")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-flat-file/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + }); + + describe("POST", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should store artefactId in session and redirect to confirmation page", async () => { + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockRequest.session.resubmissionArtefactId).toBe("abc-123"); + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-confirm-resubmission?artefactId=abc-123"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts new file mode 100644 index 00000000..a9eb9b65 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts @@ -0,0 +1,74 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { getArtefactMetadata, getFlatFileUrl } from "@hmcts/publication"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import "../../types/session.js"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + try { + const metadata = await getArtefactMetadata(artefactId); + const flatFileUrl = await getFlatFileUrl(artefactId); + + if (!metadata) { + return res.render("blob-explorer-flat-file/index", { + ...t, + error: t.flatFileError, + locale + }); + } + + res.render("blob-explorer-flat-file/index", { + ...t, + metadata, + flatFileUrl, + formatDateTime, + locale + }); + } catch (error) { + console.error("Error loading flat file:", error); + res.render("blob-explorer-flat-file/index", { + ...t, + error: t.flatFileError, + locale + }); + } +}; + +const postHandler = async (req: Request, res: Response) => { + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + // Store artefact ID in session for confirmation page + req.session.resubmissionArtefactId = artefactId; + + return res.redirect(`/blob-explorer-confirm-resubmission?artefactId=${artefactId}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts new file mode 100644 index 00000000..652928a8 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts @@ -0,0 +1,20 @@ +export const cy = { + jsonFileTitle: "Blob Explorer – JSON file", + jsonFileResubmitButton: "Re-submit subscription", + jsonFileMetadataHeading: "Metadata", + jsonFileAccordionTitle: "View Raw JSON Content", + jsonFileLinkToTemplate: "Link to rendered template", + jsonFileError: "We could not load the JSON publication.", + metadataArtefactId: "Artefact ID", + metadataLocationId: "Location ID", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts new file mode 100644 index 00000000..25eb84e5 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts @@ -0,0 +1,20 @@ +export const en = { + jsonFileTitle: "Blob Explorer – JSON file", + jsonFileResubmitButton: "Re-submit subscription", + jsonFileMetadataHeading: "Metadata", + jsonFileAccordionTitle: "View Raw JSON Content", + jsonFileLinkToTemplate: "Link to rendered template", + jsonFileError: "We could not load the JSON publication.", + metadataArtefactId: "Artefact ID", + metadataLocationId: "Location ID", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk new file mode 100644 index 00000000..a5948152 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk @@ -0,0 +1,143 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/details/macro.njk" import govukDetails %} + +{% block content %} +
+
+ +

{{ jsonFileTitle }}

+ + {% if error %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: error + } + ] + }) }} + {% else %} + +
+ {{ govukButton({ + text: jsonFileResubmitButton, + classes: "govuk-button" + }) }} + + +

{{ jsonFileMetadataHeading }}

+ + {{ govukTable({ + rows: [ + [ + { + text: metadataArtefactId + }, + { + text: metadata.artefactId + } + ], + [ + { + text: metadataLocationId + }, + { + text: metadata.locationId + } + ], + [ + { + text: metadataLocationName + }, + { + text: metadata.locationName + } + ], + [ + { + text: metadataPublicationType + }, + { + text: metadata.publicationType + } + ], + [ + { + text: metadataListType + }, + { + text: metadata.listType + } + ], + [ + { + text: metadataProvenance + }, + { + text: metadata.provenance + } + ], + [ + { + text: metadataLanguage + }, + { + text: metadata.language + } + ], + [ + { + text: metadataSensitivity + }, + { + text: metadata.sensitivity + } + ], + [ + { + text: metadataContentDate + }, + { + text: formatDateTime(metadata.contentDate) + } + ], + [ + { + text: metadataDisplayFrom + }, + { + text: formatDateTime(metadata.displayFrom) + } + ], + [ + { + text: metadataDisplayTo + }, + { + text: formatDateTime(metadata.displayTo) + } + ] + ] + }) }} + + {% if renderedTemplateUrl %} +

+ {{ jsonFileLinkToTemplate }} +

+ {% endif %} + + {% if jsonContent %} + {{ govukDetails({ + summaryText: jsonFileAccordionTitle, + html: '
' + jsonContent + '
' + }) }} + {% endif %} + + {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts new file mode 100644 index 00000000..b56e3cc3 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts @@ -0,0 +1,169 @@ +import * as publication from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + getArtefactMetadata: vi.fn(), + getJsonContent: vi.fn(), + getRenderedTemplateUrl: vi.fn() +})); + +const { GET, POST } = await import("./index.js"); + +describe("blob-explorer-json-file page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: { artefactId: "abc-123" }, + session: {} as any + }; + + mockResponse = { + render: vi.fn(), + redirect: vi.fn() + }; + }); + + describe("GET", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should render the blob-explorer-json-file page with English content", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + const mockJsonContent = { cases: [{ id: "1", name: "Test Case" }] }; + const mockRenderedUrl = "https://example.com/rendered.html"; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue(mockJsonContent); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue(mockRenderedUrl); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(publication.getArtefactMetadata).toHaveBeenCalledWith("abc-123"); + expect(publication.getJsonContent).toHaveBeenCalledWith("abc-123"); + expect(publication.getRenderedTemplateUrl).toHaveBeenCalledWith("abc-123"); + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-json-file/index", + expect.objectContaining({ + metadata: mockMetadata, + jsonContent: JSON.stringify(mockJsonContent, null, 2), + renderedTemplateUrl: mockRenderedUrl, + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-json-file page with Welsh content when lng=cy", async () => { + mockRequest.query = { artefactId: "abc-123", lng: "cy" }; + + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue({ test: "data" }); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue("https://example.com/rendered.html"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-json-file/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + + it("should handle null jsonContent", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue(null); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue("https://example.com/rendered.html"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-json-file/index", + expect.objectContaining({ + jsonContent: null + }) + ); + }); + + it("should render error when metadata is null", async () => { + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(null); + vi.mocked(publication.getJsonContent).mockResolvedValue(null); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-json-file/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + + it("should render error when service fails", async () => { + vi.mocked(publication.getArtefactMetadata).mockRejectedValue(new Error("Database error")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-json-file/index", + expect.objectContaining({ + error: expect.any(String) + }) + ); + }); + }); + + describe("POST", () => { + it("should redirect to blob-explorer-locations when artefactId is missing", async () => { + mockRequest.query = {}; + + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should store artefactId in session and redirect to confirmation page", async () => { + const handler = POST[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockRequest.session.resubmissionArtefactId).toBe("abc-123"); + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-confirm-resubmission?artefactId=abc-123"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts new file mode 100644 index 00000000..1021c8d2 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts @@ -0,0 +1,76 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { getArtefactMetadata, getJsonContent, getRenderedTemplateUrl } from "@hmcts/publication"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import "../../types/session.js"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + try { + const metadata = await getArtefactMetadata(artefactId); + const jsonContent = await getJsonContent(artefactId); + const renderedTemplateUrl = await getRenderedTemplateUrl(artefactId); + + if (!metadata) { + return res.render("blob-explorer-json-file/index", { + ...t, + error: t.jsonFileError, + locale + }); + } + + res.render("blob-explorer-json-file/index", { + ...t, + metadata, + jsonContent: jsonContent ? JSON.stringify(jsonContent, null, 2) : null, + renderedTemplateUrl, + formatDateTime, + locale + }); + } catch (error) { + console.error("Error loading JSON file:", error); + res.render("blob-explorer-json-file/index", { + ...t, + error: t.jsonFileError, + locale + }); + } +}; + +const postHandler = async (req: Request, res: Response) => { + const artefactId = req.query.artefactId as string; + + if (!artefactId) { + return res.redirect("/blob-explorer-locations"); + } + + // Store artefact ID in session for confirmation page + req.session.resubmissionArtefactId = artefactId; + + return res.redirect(`/blob-explorer-confirm-resubmission?artefactId=${artefactId}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts new file mode 100644 index 00000000..2d2b10fe --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts @@ -0,0 +1,8 @@ +export const cy = { + locationsTitle: "Blob Explorer Locations", + locationsDescription: "Choose a location to see all publications associated with it.", + locationsTableHeadingLocation: "Location", + locationsTableHeadingPublications: "Number of publications per venue", + locationsError: "We could not load locations. Try again later.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts new file mode 100644 index 00000000..6d51e58c --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts @@ -0,0 +1,8 @@ +export const en = { + locationsTitle: "Blob Explorer Locations", + locationsDescription: "Choose a location to see all publications associated with it.", + locationsTableHeadingLocation: "Location", + locationsTableHeadingPublications: "Number of publications per venue", + locationsError: "We could not load locations. Try again later.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.njk new file mode 100644 index 00000000..1bc1d6ed --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.njk @@ -0,0 +1,43 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + +{% block content %} +
+
+ +

{{ locationsTitle }}

+ +

{{ locationsDescription }}

+ + {% if error %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: error + } + ] + }) }} + {% endif %} + + {% if tableRows.length > 0 %} + {{ govukTable({ + head: [ + { + text: locationsTableHeadingLocation + }, + { + text: locationsTableHeadingPublications, + format: "numeric" + } + ], + rows: tableRows + }) }} + {% else %} +

No publications found. Publications need to be ingested before they can be viewed here.

+ {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.test.ts new file mode 100644 index 00000000..cb5b16cb --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.test.ts @@ -0,0 +1,106 @@ +import * as publication from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + getLocationsWithPublicationCount: vi.fn() +})); + +const { GET } = await import("./index.js"); + +describe("blob-explorer-locations page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: {} + }; + + mockResponse = { + render: vi.fn() + }; + }); + + describe("GET", () => { + it("should render the blob-explorer-locations page with English content", async () => { + const mockLocations = [ + { locationId: "1", locationName: "Location 1", publicationCount: 5 }, + { locationId: "2", locationName: "Location 2", publicationCount: 3 } + ]; + + vi.mocked(publication.getLocationsWithPublicationCount).mockResolvedValue(mockLocations); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(publication.getLocationsWithPublicationCount).toHaveBeenCalled(); + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-locations/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([expect.objectContaining({ html: expect.stringContaining("Location 1") }), expect.objectContaining({ text: "5" })]) + ]), + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-locations page with Welsh content when lng=cy", async () => { + mockRequest.query = { lng: "cy" }; + + const mockLocations = [{ locationId: "1", locationName: "Location 1", publicationCount: 5 }]; + + vi.mocked(publication.getLocationsWithPublicationCount).mockResolvedValue(mockLocations); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-locations/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + + it("should render error when service fails", async () => { + vi.mocked(publication.getLocationsWithPublicationCount).mockRejectedValue(new Error("Database error")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-locations/index", + expect.objectContaining({ + error: expect.any(String), + tableRows: [] + }) + ); + }); + + it("should generate correct links to publications page", async () => { + const mockLocations = [{ locationId: "123", locationName: "Test Location", publicationCount: 10 }]; + + vi.mocked(publication.getLocationsWithPublicationCount).mockResolvedValue(mockLocations); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-locations/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + html: expect.stringContaining("/blob-explorer-publications?locationId=123") + }) + ]) + ]) + }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.ts new file mode 100644 index 00000000..d7399afc --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/index.ts @@ -0,0 +1,43 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { getLocationsWithPublicationCount } from "@hmcts/publication"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + + try { + const locations = await getLocationsWithPublicationCount(); + + const tableRows = locations.map((location) => [ + { + html: `${location.locationName}` + }, + { + text: location.publicationCount.toString(), + format: "numeric" + } + ]); + + res.render("blob-explorer-locations/index", { + ...t, + tableRows, + locale + }); + } catch (error) { + console.error("Error loading locations:", error); + res.render("blob-explorer-locations/index", { + ...t, + error: t.locationsError, + tableRows: [], + locale + }); + } +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts new file mode 100644 index 00000000..d5d8afa9 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts @@ -0,0 +1,10 @@ +export const cy = { + publicationsTitle: "Blob Explorer Publications", + publicationsDescription: "Choose a publication from the list.", + publicationsTableHeadingArtefactId: "Artefact ID", + publicationsTableHeadingListType: "List type", + publicationsTableHeadingDisplayFrom: "Display from", + publicationsTableHeadingDisplayTo: "Display to", + publicationsError: "We could not load publications for this location.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts new file mode 100644 index 00000000..d1e0a121 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts @@ -0,0 +1,10 @@ +export const en = { + publicationsTitle: "Blob Explorer Publications", + publicationsDescription: "Choose a publication from the list.", + publicationsTableHeadingArtefactId: "Artefact ID", + publicationsTableHeadingListType: "List type", + publicationsTableHeadingDisplayFrom: "Display from", + publicationsTableHeadingDisplayTo: "Display to", + publicationsError: "We could not load publications for this location.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.njk new file mode 100644 index 00000000..11f59a20 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.njk @@ -0,0 +1,48 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} + +{% block content %} +
+
+ +

{{ publicationsTitle }}

+ +

{{ publicationsDescription }}

+ + {% if error %} + {{ govukErrorSummary({ + titleText: "There is a problem", + errorList: [ + { + text: error + } + ] + }) }} + {% endif %} + + {% if tableRows.length > 0 %} + {{ govukTable({ + head: [ + { + text: publicationsTableHeadingArtefactId + }, + { + text: publicationsTableHeadingListType + }, + { + text: publicationsTableHeadingDisplayFrom + }, + { + text: publicationsTableHeadingDisplayTo + } + ], + rows: tableRows + }) }} + {% else %} +

No publications found for this location.

+ {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts new file mode 100644 index 00000000..1c23aa3e --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts @@ -0,0 +1,193 @@ +import * as publication from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@hmcts/publication", () => ({ + getArtefactSummariesByLocation: vi.fn(), + getArtefactType: vi.fn() +})); + +const { GET } = await import("./index.js"); + +describe("blob-explorer-publications page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: { locationId: "123" } + }; + + mockResponse = { + render: vi.fn(), + redirect: vi.fn() + }; + }); + + describe("GET", () => { + it("should redirect to blob-explorer-locations when locationId is missing", async () => { + mockRequest.query = {}; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.redirect).toHaveBeenCalledWith("/blob-explorer-locations"); + }); + + it("should render the blob-explorer-publications page with English content", async () => { + const mockPublications = [ + { + artefactId: "abc-123", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("flat-file"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(publication.getArtefactSummariesByLocation).toHaveBeenCalledWith("123"); + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ html: expect.stringContaining("abc-123") }), + expect.objectContaining({ text: "Civil Daily Cause List" }) + ]) + ]), + locationId: "123", + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-publications page with Welsh content when lng=cy", async () => { + mockRequest.query = { locationId: "123", lng: "cy" }; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue([]); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + + it("should generate correct link for flat-file publications", async () => { + const mockPublications = [ + { + artefactId: "flat-123", + listType: "Test List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("flat-file"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + html: expect.stringContaining("/blob-explorer-flat-file?artefactId=flat-123") + }) + ]) + ]) + }) + ); + }); + + it("should generate correct link for json-file publications", async () => { + const mockPublications = [ + { + artefactId: "json-123", + listType: "Test List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("json"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([ + expect.objectContaining({ + html: expect.stringContaining("/blob-explorer-json-file?artefactId=json-123") + }) + ]) + ]) + }) + ); + }); + + it("should render error when service fails", async () => { + vi.mocked(publication.getArtefactSummariesByLocation).mockRejectedValue(new Error("Database error")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + error: expect.any(String), + tableRows: [] + }) + ); + }); + + it("should format dates correctly", async () => { + const mockPublications = [ + { + artefactId: "abc-123", + listType: "Test List", + displayFrom: "2024-01-15T10:30:00Z", + displayTo: "2024-01-16T15:45:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("flat-file"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-publications/index", + expect.objectContaining({ + tableRows: expect.arrayContaining([ + expect.arrayContaining([ + expect.anything(), + expect.anything(), + expect.objectContaining({ text: expect.stringMatching(/\d{2}\/\d{2}\/\d{4}/) }), + expect.objectContaining({ text: expect.stringMatching(/\d{2}\/\d{2}\/\d{4}/) }) + ]) + ]) + }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts new file mode 100644 index 00000000..def71212 --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts @@ -0,0 +1,74 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { getArtefactSummariesByLocation, getArtefactType } from "@hmcts/publication"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + const locationId = req.query.locationId as string; + + if (!locationId) { + return res.redirect("/blob-explorer-locations"); + } + + try { + const publications = await getArtefactSummariesByLocation(locationId); + + const tableRows = await Promise.all( + publications.map(async (pub) => { + const type = await getArtefactType(pub.artefactId); + const path = type === "flat-file" ? "flat-file" : "json-file"; + const link = `/blob-explorer-${path}?artefactId=${pub.artefactId}`; + + return [ + { + html: `${pub.artefactId}` + }, + { + text: pub.listType + }, + { + text: formatDateTime(pub.displayFrom) + }, + { + text: formatDateTime(pub.displayTo) + } + ]; + }) + ); + + res.render("blob-explorer-publications/index", { + ...t, + tableRows, + locationId, + locale + }); + } catch (error) { + console.error("Error loading publications:", error); + res.render("blob-explorer-publications/index", { + ...t, + error: t.publicationsError, + tableRows: [], + locationId, + locale + }); + } +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts new file mode 100644 index 00000000..67be23ed --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + successTitle: "Submission re-submitted", + successBanner: "Submission re-submitted.", + successNextSteps: "What do you want to do next?", + successLinkToLocations: "Blob explorer – Locations", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts new file mode 100644 index 00000000..3b0daafb --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts @@ -0,0 +1,7 @@ +export const en = { + successTitle: "Submission re-submitted", + successBanner: "Submission re-submitted.", + successNextSteps: "What do you want to do next?", + successLinkToLocations: "Blob explorer – Locations", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk new file mode 100644 index 00000000..d8571ffb --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk @@ -0,0 +1,22 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% block content %} +
+
+ + {{ govukPanel({ + titleText: successTitle, + html: successBanner, + classes: "govuk-panel--confirmation" + }) }} + +

{{ successNextSteps }}

+ +

+ {{ successLinkToLocations }} +

+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.test.ts new file mode 100644 index 00000000..a819eaaf --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.test.ts @@ -0,0 +1,49 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { GET } = await import("./index.js"); + +describe("blob-explorer-resubmission-success page", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + query: {} + }; + + mockResponse = { + render: vi.fn() + }; + }); + + describe("GET", () => { + it("should render the blob-explorer-resubmission-success page with English content", async () => { + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-resubmission-success/index", + expect.objectContaining({ + locale: "en" + }) + ); + }); + + it("should render the blob-explorer-resubmission-success page with Welsh content when lng=cy", async () => { + mockRequest.query = { lng: "cy" }; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.render).toHaveBeenCalledWith( + "blob-explorer-resubmission-success/index", + expect.objectContaining({ + locale: "cy" + }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.ts new file mode 100644 index 00000000..f600f84b --- /dev/null +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.ts @@ -0,0 +1,19 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import "@hmcts/web-core"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getTranslations = (locale: string) => (locale === "cy" ? cy : en); + +const getHandler = async (req: Request, res: Response) => { + const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; + const t = getTranslations(locale); + + res.render("blob-explorer-resubmission-success/index", { + ...t, + locale + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts b/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts index 8ae79f00..a3ee36ad 100644 --- a/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts +++ b/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts @@ -24,7 +24,7 @@ export const en = { { title: "Blob Explorer", description: "Discover content uploaded to all locations", - href: "/blob-explorer" + href: "/blob-explorer-locations" }, { title: "Bulk Create Media Accounts", diff --git a/libs/system-admin-pages/src/services/service.test.ts b/libs/system-admin-pages/src/services/service.test.ts new file mode 100644 index 00000000..d92af7b8 --- /dev/null +++ b/libs/system-admin-pages/src/services/service.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sendPublicationNotifications } from "./service.js"; + +describe("System Admin Service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("sendPublicationNotifications", () => { + it("should call console.log with correct artefactId", async () => { + const consoleSpy = vi.spyOn(console, "log"); + + await sendPublicationNotifications("test-artefact-123"); + + expect(consoleSpy).toHaveBeenCalledWith("Mock: Sending notifications for artefact test-artefact-123"); + }); + + it("should handle different artefactIds", async () => { + const consoleSpy = vi.spyOn(console, "log"); + + await sendPublicationNotifications("another-artefact-456"); + + expect(consoleSpy).toHaveBeenCalledWith("Mock: Sending notifications for artefact another-artefact-456"); + }); + + it("should return void", async () => { + const result = await sendPublicationNotifications("test-artefact"); + + expect(result).toBeUndefined(); + }); + + it("should be callable multiple times", async () => { + const consoleSpy = vi.spyOn(console, "log"); + + await sendPublicationNotifications("artefact-1"); + await sendPublicationNotifications("artefact-2"); + await sendPublicationNotifications("artefact-3"); + + expect(consoleSpy).toHaveBeenCalledTimes(3); + expect(consoleSpy).toHaveBeenNthCalledWith(1, "Mock: Sending notifications for artefact artefact-1"); + expect(consoleSpy).toHaveBeenNthCalledWith(2, "Mock: Sending notifications for artefact artefact-2"); + expect(consoleSpy).toHaveBeenNthCalledWith(3, "Mock: Sending notifications for artefact artefact-3"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/services/service.ts b/libs/system-admin-pages/src/services/service.ts new file mode 100644 index 00000000..0ef639f9 --- /dev/null +++ b/libs/system-admin-pages/src/services/service.ts @@ -0,0 +1,4 @@ +export async function sendPublicationNotifications(artefactId: string): Promise { + // Mock implementation - placeholder for future notification service integration + console.log(`Mock: Sending notifications for artefact ${artefactId}`); +} diff --git a/libs/system-admin-pages/src/types/session.ts b/libs/system-admin-pages/src/types/session.ts new file mode 100644 index 00000000..93f0d262 --- /dev/null +++ b/libs/system-admin-pages/src/types/session.ts @@ -0,0 +1,7 @@ +declare module "express-session" { + interface SessionData { + resubmissionArtefactId?: string; + } +} + +export {}; From 148a5a7887f451ce88465a50a8235506dd2258ba Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:51:24 +0000 Subject: [PATCH 3/8] VIBE-310 - Fix tests + Update dependency --- .../system-admin-dashboard/index.njk.test.ts | 2 +- package.json | 3 ++- yarn.lock | 23 +------------------ 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts b/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts index ee81d745..68dcd9a1 100644 --- a/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts +++ b/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts @@ -56,7 +56,7 @@ describe("system-admin-dashboard template", () => { expect(en.tiles[1].href).toBe("/delete-court"); expect(en.tiles[2].href).toBe("/third-party-users"); expect(en.tiles[3].href).toBe("/user-management"); - expect(en.tiles[4].href).toBe("/blob-explorer"); + expect(en.tiles[4].href).toBe("/blob-explorer-locations"); expect(en.tiles[5].href).toBe("/bulk-media-accounts"); expect(en.tiles[6].href).toBe("/audit-log-viewer"); expect(en.tiles[7].href).toBe("/location-metadata"); diff --git a/package.json b/package.json index d1e7daca..34568ac0 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "vite": "7.2.6", "glob": "13.0.0", "body-parser": "2.2.1", - "node-forge": "1.3.3" + "node-forge": "1.3.3", + "jws": "4.0.1" }, "dependencies": { "@microsoft/microsoft-graph-client": "3.0.7", diff --git a/yarn.lock b/yarn.lock index b7488507..52e807c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5151,17 +5151,6 @@ __metadata: languageName: node linkType: hard -"jwa@npm:^1.4.1": - version: 1.4.2 - resolution: "jwa@npm:1.4.2" - dependencies: - buffer-equal-constant-time: "npm:^1.0.1" - ecdsa-sig-formatter: "npm:1.0.11" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/210a544a42ca22203e8fc538835205155ba3af6a027753109f9258bdead33086bac3c25295af48ac1981f87f9c5f941bc8f70303670f54ea7dcaafb53993d92c - languageName: node - linkType: hard - "jwa@npm:^2.0.1": version: 2.0.1 resolution: "jwa@npm:2.0.1" @@ -5187,17 +5176,7 @@ __metadata: languageName: node linkType: hard -"jws@npm:^3.1.3, jws@npm:^3.2.2": - version: 3.2.2 - resolution: "jws@npm:3.2.2" - dependencies: - jwa: "npm:^1.4.1" - safe-buffer: "npm:^5.0.1" - checksum: 10c0/e770704533d92df358adad7d1261fdecad4d7b66fa153ba80d047e03ca0f1f73007ce5ed3fbc04d2eba09ba6e7e6e645f351e08e5ab51614df1b0aa4f384dfff - languageName: node - linkType: hard - -"jws@npm:^4.0.1": +"jws@npm:4.0.1": version: 4.0.1 resolution: "jws@npm:4.0.1" dependencies: From c7b740915d08cbdc0b63de1b517a911a7ae7b10c Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:01:19 +0000 Subject: [PATCH 4/8] VIBE-310 - Fixed comments --- e2e-tests/tests/blob-explorer.spec.ts | 40 +++-- .../src/repository/service.test.ts | 111 ++++++++++++++ libs/publication/src/repository/service.ts | 47 +++++- libs/system-admin-pages/src/config.ts | 1 + .../blob-explorer-confirm-resubmission/cy.ts | 10 ++ .../blob-explorer-confirm-resubmission/en.ts | 10 ++ .../index.njk | 4 +- .../src/pages/blob-explorer-json-file/cy.ts | 1 + .../src/pages/blob-explorer-json-file/en.ts | 1 + .../pages/blob-explorer-json-file/index.njk | 2 +- .../blob-explorer-json-file/index.test.ts | 100 ++++++++++-- .../pages/blob-explorer-json-file/index.ts | 13 +- .../blob-explorer-publications/index.test.ts | 52 +++++++ .../pages/blob-explorer-publications/index.ts | 17 ++- .../index.njk | 2 +- .../src/routes/files/[filename].test.ts | 143 ++++++++++++++++++ .../src/routes/files/[filename].ts | 86 +++++++++++ 17 files changed, 611 insertions(+), 29 deletions(-) create mode 100644 libs/system-admin-pages/src/routes/files/[filename].test.ts create mode 100644 libs/system-admin-pages/src/routes/files/[filename].ts diff --git a/e2e-tests/tests/blob-explorer.spec.ts b/e2e-tests/tests/blob-explorer.spec.ts index 4c8e08ac..0ab10fb3 100644 --- a/e2e-tests/tests/blob-explorer.spec.ts +++ b/e2e-tests/tests/blob-explorer.spec.ts @@ -9,6 +9,7 @@ interface TestPublicationData { artefactId: string; locationId: number; locationName: string; + userId: string; } const testPublicationMap = new Map(); @@ -84,6 +85,7 @@ async function createTestPublication(): Promise { artefactId, locationId: location.locationId, locationName: location.name, + userId: testUser.userId, }; } @@ -91,6 +93,24 @@ async function deleteTestPublication(publicationData: TestPublicationData): Prom try { if (!publicationData.artefactId) return; + // Delete subscription first (foreign key constraint) + try { + await prisma.subscription.deleteMany({ + where: { userId: publicationData.userId }, + }); + } catch (error) { + console.log("Subscription cleanup error:", error); + } + + // Delete test user + try { + await prisma.user.delete({ + where: { userId: publicationData.userId }, + }); + } catch (error) { + console.log("User cleanup error:", error); + } + // Delete artefact await prisma.artefact.delete({ where: { artefactId: publicationData.artefactId }, @@ -126,13 +146,13 @@ test.describe("Blob Explorer", () => { await expect(page.getByRole("heading", { name: /System Admin Dashboard/i })).toBeVisible(); // Find and click Blob Explorer tile - const blobExplorerTile = page.locator('a.admin-tile[href="/blob-explorer"]'); + const blobExplorerTile = page.locator('a.admin-tile[href="/blob-explorer-locations"]'); await expect(blobExplorerTile).toBeVisible(); await expect(blobExplorerTile).toContainText("Blob Explorer"); await blobExplorerTile.click(); // STEP 2: Verify Blob Explorer Locations page - await expect(page).toHaveURL("/blob-explorer"); + await expect(page).toHaveURL("/blob-explorer-locations"); await expect(page.getByRole("heading", { name: /Blob Explorer Locations/i, level: 1 })).toBeVisible(); // Test Welsh translation on locations page @@ -157,12 +177,12 @@ test.describe("Blob Explorer", () => { await expect(locationTable).toBeVisible(); // Find row with our test location and click it - const locationLink = page.locator(`a[href*="/blob-explorer/publications?locationId=${publicationData.locationId}"]`).first(); + const locationLink = page.locator(`a[href*="/blob-explorer-publications?locationId=${publicationData.locationId}"]`).first(); await expect(locationLink).toBeVisible(); await locationLink.click(); // STEP 4: Verify Publications page - await expect(page).toHaveURL(new RegExp(`/blob-explorer/publications.*locationId=${publicationData.locationId}`)); + await expect(page).toHaveURL(new RegExp(`/blob-explorer-publications.*locationId=${publicationData.locationId}`)); await expect(page.getByRole("heading", { name: /Blob Explorer Publications/i })).toBeVisible(); // Test Welsh on publications page @@ -182,12 +202,12 @@ test.describe("Blob Explorer", () => { const publicationsTable = page.locator("table.govuk-table"); await expect(publicationsTable).toBeVisible(); - const artefactLink = page.locator(`a[href*="/blob-explorer/json-file?artefactId=${publicationData.artefactId}"]`).first(); + const artefactLink = page.locator(`a[href*="/blob-explorer-json-file?artefactId=${publicationData.artefactId}"]`).first(); await expect(artefactLink).toBeVisible(); await artefactLink.click(); // STEP 6: Verify JSON File page with metadata - await expect(page).toHaveURL(new RegExp(`/blob-explorer/json-file.*artefactId=${publicationData.artefactId}`)); + await expect(page).toHaveURL(new RegExp(`/blob-explorer-json-file.*artefactId=${publicationData.artefactId}`)); await expect(page.getByRole("heading", { name: /Blob Explorer – JSON file/i })).toBeVisible(); // Verify metadata table is visible @@ -231,7 +251,7 @@ test.describe("Blob Explorer", () => { await resubmitButton.click(); // STEP 10: Verify confirmation page - await expect(page).toHaveURL(new RegExp(`/blob-explorer/confirm-resubmission.*artefactId=${publicationData.artefactId}`)); + await expect(page).toHaveURL(new RegExp(`/blob-explorer-confirm-resubmission.*artefactId=${publicationData.artefactId}`)); await expect(page.getByRole("heading", { name: /Confirm subscription re-submission/i })).toBeVisible(); // Verify summary table @@ -267,7 +287,7 @@ test.describe("Blob Explorer", () => { await confirmButton.click(); // STEP 12: Verify success page - await expect(page).toHaveURL("/blob-explorer/resubmission-success"); + await expect(page).toHaveURL("/blob-explorer-resubmission-success"); await expect(page.getByRole("heading", { name: /Submission re-submitted/i })).toBeVisible(); // Verify success panel/banner @@ -289,14 +309,14 @@ test.describe("Blob Explorer", () => { // STEP 13: Test POST/Redirect/GET pattern - browser back should not re-submit await page.goBack(); - await expect(page).toHaveURL("/blob-explorer/resubmission-success"); + await expect(page).toHaveURL("/blob-explorer-resubmission-success"); // Should still be on success page, not trigger another submission // STEP 14: Navigate back to locations const locationsLink = page.getByRole("link", { name: /Blob explorer – Locations/i }); await expect(locationsLink).toBeVisible(); await locationsLink.click(); - await expect(page).toHaveURL("/blob-explorer"); + await expect(page).toHaveURL("/blob-explorer-locations"); } finally { // Cleanup test publication diff --git a/libs/publication/src/repository/service.test.ts b/libs/publication/src/repository/service.test.ts index d2f73280..e77547be 100644 --- a/libs/publication/src/repository/service.test.ts +++ b/libs/publication/src/repository/service.test.ts @@ -74,6 +74,54 @@ describe("Publication Service", () => { expect(result).toBeNull(); }); + + it("should reject path traversal attempts with ../", async () => { + const result = await getJsonContent("../../etc/passwd"); + + expect(result).toBeNull(); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("should reject artefactId with directory separators", async () => { + const result = await getJsonContent("subdir/malicious"); + + expect(result).toBeNull(); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("should reject artefactId with backslashes", async () => { + const result = await getJsonContent("..\\..\\windows\\system32"); + + expect(result).toBeNull(); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("should reject artefactId with null bytes", async () => { + const result = await getJsonContent("test\x00malicious"); + + expect(result).toBeNull(); + expect(fs.readFile).not.toHaveBeenCalled(); + }); + + it("should accept valid UUID format artefactIds", async () => { + const mockContent = { test: "data" }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockContent)); + + const result = await getJsonContent("550e8400-e29b-41d4-a716-446655440000"); + + expect(result).toEqual(mockContent); + expect(fs.readFile).toHaveBeenCalled(); + }); + + it("should accept artefactIds with underscores", async () => { + const mockContent = { test: "data" }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockContent)); + + const result = await getJsonContent("test_artefact_123"); + + expect(result).toEqual(mockContent); + expect(fs.readFile).toHaveBeenCalled(); + }); }); describe("getRenderedTemplateUrl", () => { @@ -117,6 +165,38 @@ describe("Publication Service", () => { expect(result).toBe("/family-daily-cause-list?artefactId=test-artefact-id-2"); }); + + it("should URL-encode artefactId with special characters", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(1); + + const result = await getRenderedTemplateUrl("test&id=malicious"); + + expect(result).toBe("/civil-daily-cause-list?artefactId=test%26id%3Dmalicious"); + }); + + it("should URL-encode artefactId with question marks", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(1); + + const result = await getRenderedTemplateUrl("test?query=param"); + + expect(result).toBe("/civil-daily-cause-list?artefactId=test%3Fquery%3Dparam"); + }); + + it("should URL-encode artefactId with spaces", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(1); + + const result = await getRenderedTemplateUrl("test artefact id"); + + expect(result).toBe("/civil-daily-cause-list?artefactId=test%20artefact%20id"); + }); + + it("should URL-encode artefactId with ampersands", async () => { + vi.mocked(queries.getArtefactListTypeId).mockResolvedValue(1); + + const result = await getRenderedTemplateUrl("test¶m=value&other=data"); + + expect(result).toBe("/civil-daily-cause-list?artefactId=test%26param%3Dvalue%26other%3Ddata"); + }); }); describe("getFlatFileUrl", () => { @@ -160,5 +240,36 @@ describe("Publication Service", () => { expect(result).toBe("/files/test-artefact-id.pdf"); }); + + it("should reject path traversal attempts in artefactId", async () => { + const result = await getFlatFileUrl("../../etc/passwd"); + + expect(result).toBeNull(); + expect(fs.readdir).not.toHaveBeenCalled(); + }); + + it("should reject artefactId with forward slashes", async () => { + const result = await getFlatFileUrl("subdir/malicious"); + + expect(result).toBeNull(); + expect(fs.readdir).not.toHaveBeenCalled(); + }); + + it("should reject matched filenames with path traversal", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["../../../malicious.pdf", "test-artefact-id.pdf"] as any); + + // Use valid artefactId but readdir returns malicious filename + const result = await getFlatFileUrl("../../../malicious"); + + expect(result).toBeNull(); + }); + + it("should reject matched filenames with directory separators", async () => { + vi.mocked(fs.readdir).mockResolvedValue(["subdir/malicious.pdf"] as any); + + const result = await getFlatFileUrl("test-id"); + + expect(result).toBeNull(); + }); }); }); diff --git a/libs/publication/src/repository/service.ts b/libs/publication/src/repository/service.ts index db0e64dc..81325956 100644 --- a/libs/publication/src/repository/service.ts +++ b/libs/publication/src/repository/service.ts @@ -9,9 +9,40 @@ const __dirname = path.dirname(__filename); const MONOREPO_ROOT = path.join(__dirname, "..", "..", "..", ".."); const TEMP_STORAGE_BASE = path.join(MONOREPO_ROOT, "storage", "temp", "uploads"); +function isValidArtefactId(artefactId: string): boolean { + // Only allow alphanumeric characters, hyphens, and underscores (typical UUID format) + const validPattern = /^[a-zA-Z0-9_-]+$/; + return validPattern.test(artefactId); +} + +function getSafeFilePath(artefactId: string, filename: string): string | null { + // Validate artefactId format + if (!isValidArtefactId(artefactId)) { + return null; + } + + // Create the path + const filePath = path.join(TEMP_STORAGE_BASE, filename); + + // Resolve both paths to absolute and normalize them + const resolvedPath = path.resolve(filePath); + const resolvedBase = path.resolve(TEMP_STORAGE_BASE); + + // Ensure the resolved path is within the base directory + if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { + return null; + } + + return resolvedPath; +} + export async function getJsonContent(artefactId: string): Promise { try { - const filePath = path.join(TEMP_STORAGE_BASE, `${artefactId}.json`); + const filePath = getSafeFilePath(artefactId, `${artefactId}.json`); + if (!filePath) { + return null; + } + const content = await fs.readFile(filePath, "utf-8"); return JSON.parse(content); } catch { @@ -31,11 +62,16 @@ export async function getRenderedTemplateUrl(artefactId: string): Promise { try { + // Validate artefactId before using it + if (!isValidArtefactId(artefactId)) { + return null; + } + const files = await fs.readdir(TEMP_STORAGE_BASE); const fileMatch = files.find((file) => file.startsWith(`${artefactId}.`)); @@ -43,7 +79,12 @@ export async function getFlatFileUrl(artefactId: string): Promise return null; } - return `/files/${fileMatch}`; + // Validate the matched filename doesn't contain path traversal + if (fileMatch.includes("..") || fileMatch.includes("/") || fileMatch.includes("\\")) { + return null; + } + + return `/files/${encodeURIComponent(fileMatch)}`; } catch { return null; } diff --git a/libs/system-admin-pages/src/config.ts b/libs/system-admin-pages/src/config.ts index 504dc899..203a6925 100644 --- a/libs/system-admin-pages/src/config.ts +++ b/libs/system-admin-pages/src/config.ts @@ -5,5 +5,6 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export const pageRoutes = { path: path.join(__dirname, "pages") }; +export const apiRoutes = { path: path.join(__dirname, "routes") }; export const assets = path.join(__dirname, "assets/"); export const moduleRoot = __dirname; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts index d3f9e934..4273f840 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts @@ -3,5 +3,15 @@ export const cy = { confirmButton: "Confirm", confirmCancelLink: "Cancel", confirmError: "We could not re-submit this publication. Try again later.", + errorSummaryTitle: "There is a problem", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", back: "Back" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts index 0ea909f2..ea1da581 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts @@ -3,5 +3,15 @@ export const en = { confirmButton: "Confirm", confirmCancelLink: "Cancel", confirmError: "We could not re-submit this publication. Try again later.", + errorSummaryTitle: "There is a problem", + metadataLocationName: "Location Name", + metadataPublicationType: "Publication Type", + metadataListType: "List Type", + metadataProvenance: "Provenance", + metadataLanguage: "Language", + metadataSensitivity: "Sensitivity", + metadataContentDate: "Content Date", + metadataDisplayFrom: "Display From", + metadataDisplayTo: "Display To", back: "Back" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk index 0c6265db..ee6f2616 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.njk @@ -11,7 +11,7 @@ {% if error %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: errorSummaryTitle, errorList: [ { text: error @@ -105,7 +105,7 @@

- {{ confirmCancelLink }} + {{ confirmCancelLink }}

{% endif %} diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts index 652928a8..d7d671bf 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts @@ -5,6 +5,7 @@ export const cy = { jsonFileAccordionTitle: "View Raw JSON Content", jsonFileLinkToTemplate: "Link to rendered template", jsonFileError: "We could not load the JSON publication.", + errorSummaryTitle: "There is a problem", metadataArtefactId: "Artefact ID", metadataLocationId: "Location ID", metadataLocationName: "Location Name", diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts index 25eb84e5..36af678a 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts @@ -5,6 +5,7 @@ export const en = { jsonFileAccordionTitle: "View Raw JSON Content", jsonFileLinkToTemplate: "Link to rendered template", jsonFileError: "We could not load the JSON publication.", + errorSummaryTitle: "There is a problem", metadataArtefactId: "Artefact ID", metadataLocationId: "Location ID", metadataLocationName: "Location Name", diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk index a5948152..163810a1 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.njk @@ -12,7 +12,7 @@ {% if error %} {{ govukErrorSummary({ - titleText: "There is a problem", + titleText: errorSummaryTitle, errorList: [ { text: error diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts index b56e3cc3..a42d782a 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.test.ts @@ -58,15 +58,15 @@ describe("blob-explorer-json-file page", () => { expect(publication.getArtefactMetadata).toHaveBeenCalledWith("abc-123"); expect(publication.getJsonContent).toHaveBeenCalledWith("abc-123"); expect(publication.getRenderedTemplateUrl).toHaveBeenCalledWith("abc-123"); - expect(mockResponse.render).toHaveBeenCalledWith( - "blob-explorer-json-file/index", - expect.objectContaining({ - metadata: mockMetadata, - jsonContent: JSON.stringify(mockJsonContent, null, 2), - renderedTemplateUrl: mockRenderedUrl, - locale: "en" - }) - ); + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + expect(renderCall[0]).toBe("blob-explorer-json-file/index"); + expect(renderCall[1]).toMatchObject({ + metadata: mockMetadata, + renderedTemplateUrl: mockRenderedUrl, + locale: "en" + }); + // jsonContent should be present and escaped + expect(renderCall[1].jsonContent).toBeTruthy(); }); it("should render the blob-explorer-json-file page with Welsh content when lng=cy", async () => { @@ -146,6 +146,88 @@ describe("blob-explorer-json-file page", () => { }) ); }); + + it("should escape HTML in jsonContent to prevent XSS", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + const mockJsonContent = { + malicious: "", + normal: "safe content" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue(mockJsonContent); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + const jsonContent = renderCall[1].jsonContent; + + // Verify HTML entities are escaped + expect(jsonContent).toContain("<script>"); + expect(jsonContent).toContain("</script>"); + expect(jsonContent).not.toContain(""); + }); + + it("should escape ampersands in jsonContent", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + const mockJsonContent = { + text: "Text 1", + url: "http://example.com?foo=bar&baz=qux" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue(mockJsonContent); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + const jsonContent = renderCall[1].jsonContent; + + // Verify ampersands are escaped + expect(jsonContent).toContain("&"); + }); + + it("should escape quotes in jsonContent", async () => { + const mockMetadata = { + locationName: "Test Court", + listType: "Civil Daily Cause List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + }; + const mockJsonContent = { + text: "Text 2", + attribute: "onclick='malicious()'" + }; + + vi.mocked(publication.getArtefactMetadata).mockResolvedValue(mockMetadata); + vi.mocked(publication.getJsonContent).mockResolvedValue(mockJsonContent); + vi.mocked(publication.getRenderedTemplateUrl).mockResolvedValue(null); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + const jsonContent = renderCall[1].jsonContent; + + // Verify quotes are escaped + expect(jsonContent).toContain("""); + expect(jsonContent).toContain("'"); + }); }); describe("POST", () => { diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts index 1021c8d2..7525c6ca 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts @@ -19,6 +19,17 @@ function formatDateTime(isoString: string): string { }); } +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, (char) => map[char]); +} + const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); @@ -44,7 +55,7 @@ const getHandler = async (req: Request, res: Response) => { res.render("blob-explorer-json-file/index", { ...t, metadata, - jsonContent: jsonContent ? JSON.stringify(jsonContent, null, 2) : null, + jsonContent: jsonContent ? escapeHtml(JSON.stringify(jsonContent, null, 2)) : null, renderedTemplateUrl, formatDateTime, locale diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts index 1c23aa3e..20a12fd1 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.test.ts @@ -189,5 +189,57 @@ describe("blob-explorer-publications page", () => { }) ); }); + + it("should escape HTML in artefactId to prevent XSS", async () => { + const mockPublications = [ + { + artefactId: '', + listType: "Test List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("json"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + const tableRows = renderCall[1].tableRows; + const htmlContent = tableRows[0][0].html; + + // Verify HTML entities are escaped + expect(htmlContent).toContain("<script>"); + expect(htmlContent).toContain("</script>"); + expect(htmlContent).not.toContain(""); + }); + + it("should URL-encode artefactId in query parameters", async () => { + const mockPublications = [ + { + artefactId: "test id with spaces & special=chars", + listType: "Test List", + displayFrom: "2024-01-01T00:00:00Z", + displayTo: "2024-01-02T00:00:00Z" + } + ]; + + vi.mocked(publication.getArtefactSummariesByLocation).mockResolvedValue(mockPublications); + vi.mocked(publication.getArtefactType).mockResolvedValue("json"); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + const renderCall = vi.mocked(mockResponse.render).mock.calls[0]; + const tableRows = renderCall[1].tableRows; + const htmlContent = tableRows[0][0].html; + + // Verify URL encoding + expect(htmlContent).toContain("artefactId=test%20id%20with%20spaces%20%26%20special%3Dchars"); + expect(htmlContent).not.toContain("artefactId=test id with spaces & special=chars"); + }); }); }); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts index def71212..8fa7d6f3 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts @@ -18,6 +18,17 @@ function formatDateTime(isoString: string): string { }); } +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, (char) => map[char]); +} + const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); @@ -34,11 +45,13 @@ const getHandler = async (req: Request, res: Response) => { publications.map(async (pub) => { const type = await getArtefactType(pub.artefactId); const path = type === "flat-file" ? "flat-file" : "json-file"; - const link = `/blob-explorer-${path}?artefactId=${pub.artefactId}`; + const encodedArtefactId = encodeURIComponent(pub.artefactId); + const link = `/blob-explorer-${path}?artefactId=${encodedArtefactId}`; + const escapedArtefactId = escapeHtml(pub.artefactId); return [ { - html: `${pub.artefactId}` + html: `${escapedArtefactId}` }, { text: pub.listType diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk index d8571ffb..63acebe2 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/index.njk @@ -14,7 +14,7 @@

{{ successNextSteps }}

- {{ successLinkToLocations }} + {{ successLinkToLocations }}

diff --git a/libs/system-admin-pages/src/routes/files/[filename].test.ts b/libs/system-admin-pages/src/routes/files/[filename].test.ts new file mode 100644 index 00000000..98172c56 --- /dev/null +++ b/libs/system-admin-pages/src/routes/files/[filename].test.ts @@ -0,0 +1,143 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", () => ({ + default: { + access: vi.fn(), + readFile: vi.fn() + } +})); + +const { GET } = await import("./[filename].js"); + +describe("files route", () => { + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequest = { + params: { filename: "test-artefact.pdf" } + }; + + mockResponse = { + setHeader: vi.fn(), + send: vi.fn(), + status: vi.fn().mockReturnThis() + }; + }); + + it("should serve a PDF file with correct headers", async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("fake pdf content")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "application/pdf"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Disposition", 'inline; filename="test-artefact.pdf"'); + expect(mockResponse.send).toHaveBeenCalledWith(Buffer.from("fake pdf content")); + }); + + it("should serve a CSV file with correct content type", async () => { + mockRequest.params = { filename: "data.csv" }; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("csv,content")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "text/csv"); + expect(mockResponse.send).toHaveBeenCalledWith(Buffer.from("csv,content")); + }); + + it("should serve a JSON file with correct content type", async () => { + mockRequest.params = { filename: "data.json" }; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('{"key": "value"}')); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "application/json"); + expect(mockResponse.send).toHaveBeenCalled(); + }); + + it("should return 400 when filename is missing", async () => { + mockRequest.params = {}; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.send).toHaveBeenCalledWith("Filename is required"); + }); + + it("should return 400 for invalid filename with path traversal", async () => { + mockRequest.params = { filename: "../etc/passwd" }; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.send).toHaveBeenCalledWith("Invalid filename"); + }); + + it("should return 400 for filename with forward slash", async () => { + mockRequest.params = { filename: "subdir/file.pdf" }; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.send).toHaveBeenCalledWith("Invalid filename"); + }); + + it("should return 400 for filename with backslash", async () => { + mockRequest.params = { filename: "subdir\\file.pdf" }; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.send).toHaveBeenCalledWith("Invalid filename"); + }); + + it("should return 400 for filename without extension", async () => { + mockRequest.params = { filename: "noextension" }; + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.send).toHaveBeenCalledWith("Invalid filename"); + }); + + it("should return 404 when file does not exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error("ENOENT")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.send).toHaveBeenCalledWith("File not found"); + }); + + it("should use application/octet-stream for unknown file types", async () => { + mockRequest.params = { filename: "file.xyz" }; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("content")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "application/octet-stream"); + expect(mockResponse.send).toHaveBeenCalled(); + }); +}); diff --git a/libs/system-admin-pages/src/routes/files/[filename].ts b/libs/system-admin-pages/src/routes/files/[filename].ts new file mode 100644 index 00000000..00e4003b --- /dev/null +++ b/libs/system-admin-pages/src/routes/files/[filename].ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MONOREPO_ROOT = path.join(__dirname, "..", "..", "..", "..", ".."); +const TEMP_STORAGE_BASE = path.join(MONOREPO_ROOT, "storage", "temp", "uploads"); + +function isValidFilename(filename: string): boolean { + // Only allow alphanumeric, hyphens, underscores, and dots for file extensions + const validPattern = /^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+$/; + return validPattern.test(filename); +} + +function getSafeFilePath(filename: string): string | null { + // Validate filename format + if (!isValidFilename(filename)) { + return null; + } + + // Additional security: ensure no path traversal characters + if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) { + return null; + } + + // Create the path + const filePath = path.join(TEMP_STORAGE_BASE, filename); + + // Resolve both paths to absolute and normalize them + const resolvedPath = path.resolve(filePath); + const resolvedBase = path.resolve(TEMP_STORAGE_BASE); + + // Ensure the resolved path is within the base directory + if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) { + return null; + } + + return resolvedPath; +} + +const getHandler = async (req: Request, res: Response) => { + const filename = req.params.filename; + + if (!filename) { + return res.status(400).send("Filename is required"); + } + + try { + const filePath = getSafeFilePath(filename); + + if (!filePath) { + return res.status(400).send("Invalid filename"); + } + + // Check if file exists + await fs.access(filePath); + + // Set appropriate content type based on file extension + const ext = path.extname(filename).toLowerCase(); + const contentTypes: Record = { + ".pdf": "application/pdf", + ".csv": "text/csv", + ".txt": "text/plain", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html" + }; + + const contentType = contentTypes[ext] || "application/octet-stream"; + + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + + // Stream the file + const fileContent = await fs.readFile(filePath); + res.send(fileContent); + } catch (error) { + console.error("Error serving file:", error); + res.status(404).send("File not found"); + } +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; From 2016c5c7d505573f6a209f231f921840f62b7cd7 Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:01:48 +0000 Subject: [PATCH 5/8] VIBE-310 - Comments --- apps/web/src/app.test.ts | 8 ++++---- apps/web/src/app.ts | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app.test.ts b/apps/web/src/app.test.ts index e983c0cf..104f8ee9 100644 --- a/apps/web/src/app.test.ts +++ b/apps/web/src/app.test.ts @@ -133,16 +133,16 @@ describe("Web Application", () => { it("should register public pages routes", async () => { const { createSimpleRouter } = await import("@hmcts/simple-router"); - // Should be called 8 times: location API routes, civil-family-cause-list pages, web pages, auth routes, public pages, verified pages, system-admin pages, admin routes - expect(createSimpleRouter).toHaveBeenCalledTimes(8); + // Should be called 9 times: location API routes, system-admin API routes, civil-family-cause-list pages, web pages, auth routes, public pages, verified pages, system-admin pages, admin routes + expect(createSimpleRouter).toHaveBeenCalledTimes(9); }); it("should register system-admin page routes", async () => { const { createSimpleRouter } = await import("@hmcts/simple-router"); const calls = vi.mocked(createSimpleRouter).mock.calls; - // Verify system-admin routes were registered (should have 8 total calls) - expect(calls.length).toBeGreaterThanOrEqual(8); + // Verify system-admin routes were registered (should have 9 total calls) + expect(calls.length).toBeGreaterThanOrEqual(9); }); it("should configure error handlers at the end", async () => { diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts index 84528285..abe2b0a3 100644 --- a/apps/web/src/app.ts +++ b/apps/web/src/app.ts @@ -10,7 +10,7 @@ import { moduleRoot as listTypesCommonModuleRoot } from "@hmcts/list-types-commo import { apiRoutes as locationApiRoutes } from "@hmcts/location/config"; import { moduleRoot as publicPagesModuleRoot, pageRoutes as publicPagesRoutes } from "@hmcts/public-pages/config"; import { createSimpleRouter } from "@hmcts/simple-router"; -import { moduleRoot as systemAdminModuleRoot, pageRoutes as systemAdminPageRoutes } from "@hmcts/system-admin-pages/config"; +import { apiRoutes as systemAdminApiRoutes, moduleRoot as systemAdminModuleRoot, pageRoutes as systemAdminPageRoutes } from "@hmcts/system-admin-pages/config"; import { moduleRoot as verifiedPagesModuleRoot, pageRoutes as verifiedPagesRoutes } from "@hmcts/verified-pages/config"; import { configureCookieManager, @@ -98,6 +98,9 @@ export async function createApp(): Promise { // Register API routes for location autocomplete app.use(await createSimpleRouter(locationApiRoutes)); + // Register API routes for system admin (file serving) + app.use(await createSimpleRouter(systemAdminApiRoutes)); + // Register civil-and-family-daily-cause-list routes first to ensure proper route matching app.use(await createSimpleRouter(civilFamilyCauseListRoutes)); From b4d627ff7920b8116eadb166e7864c5ac52a2a30 Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:33:15 +0000 Subject: [PATCH 6/8] VIBE-310 - Code Review --- .../blob-explorer-confirm-resubmission/cy.ts | 3 +-- .../blob-explorer-confirm-resubmission/en.ts | 3 +-- .../index.ts | 12 +--------- .../src/pages/blob-explorer-flat-file/cy.ts | 3 +-- .../src/pages/blob-explorer-flat-file/en.ts | 3 +-- .../pages/blob-explorer-flat-file/index.ts | 12 +--------- .../src/pages/blob-explorer-json-file/cy.ts | 3 +-- .../src/pages/blob-explorer-json-file/en.ts | 3 +-- .../pages/blob-explorer-json-file/index.ts | 23 +------------------ .../src/pages/blob-explorer-locations/cy.ts | 3 +-- .../src/pages/blob-explorer-locations/en.ts | 3 +-- .../pages/blob-explorer-publications/cy.ts | 3 +-- .../pages/blob-explorer-publications/en.ts | 3 +-- .../pages/blob-explorer-publications/index.ts | 23 +------------------ .../blob-explorer-resubmission-success/cy.ts | 3 +-- .../blob-explorer-resubmission-success/en.ts | 3 +-- .../src/services/formatting.ts | 21 +++++++++++++++++ libs/system-admin-pages/src/types/session.ts | 2 -- 18 files changed, 37 insertions(+), 92 deletions(-) create mode 100644 libs/system-admin-pages/src/services/formatting.ts diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts index 4273f840..5949be2f 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/cy.ts @@ -12,6 +12,5 @@ export const cy = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts index ea1da581..3cdd50eb 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/en.ts @@ -12,6 +12,5 @@ export const en = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts index c9a7f0c9..0366d6ab 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-confirm-resubmission/index.ts @@ -2,6 +2,7 @@ import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getArtefactMetadata } from "@hmcts/publication"; import "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; +import { formatDateTime } from "../../services/formatting.js"; import "../../types/session.js"; import { sendPublicationNotifications } from "../../services/service.js"; import { cy } from "./cy.js"; @@ -9,17 +10,6 @@ import { en } from "./en.js"; const getTranslations = (locale: string) => (locale === "cy" ? cy : en); -function formatDateTime(isoString: string): string { - const date = new Date(isoString); - return date.toLocaleString("en-GB", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -} - const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts index 2440623e..a925fcf8 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/cy.ts @@ -14,6 +14,5 @@ export const cy = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts index 4940a63d..63e91d91 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/en.ts @@ -14,6 +14,5 @@ export const en = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts index a9eb9b65..58c3149b 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-flat-file/index.ts @@ -2,23 +2,13 @@ import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getArtefactMetadata, getFlatFileUrl } from "@hmcts/publication"; import "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; +import { formatDateTime } from "../../services/formatting.js"; import "../../types/session.js"; import { cy } from "./cy.js"; import { en } from "./en.js"; const getTranslations = (locale: string) => (locale === "cy" ? cy : en); -function formatDateTime(isoString: string): string { - const date = new Date(isoString); - return date.toLocaleString("en-GB", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -} - const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts index d7d671bf..ac98a219 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/cy.ts @@ -16,6 +16,5 @@ export const cy = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts index 36af678a..ee261796 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/en.ts @@ -16,6 +16,5 @@ export const en = { metadataSensitivity: "Sensitivity", metadataContentDate: "Content Date", metadataDisplayFrom: "Display From", - metadataDisplayTo: "Display To", - back: "Back" + metadataDisplayTo: "Display To" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts index 7525c6ca..893ccb98 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts @@ -2,34 +2,13 @@ import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getArtefactMetadata, getJsonContent, getRenderedTemplateUrl } from "@hmcts/publication"; import "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; +import { escapeHtml, formatDateTime } from "../../services/formatting.js"; import "../../types/session.js"; import { cy } from "./cy.js"; import { en } from "./en.js"; const getTranslations = (locale: string) => (locale === "cy" ? cy : en); -function formatDateTime(isoString: string): string { - const date = new Date(isoString); - return date.toLocaleString("en-GB", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -} - -function escapeHtml(text: string): string { - const map: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }; - return text.replace(/[&<>"']/g, (char) => map[char]); -} - const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts index 2d2b10fe..02bce155 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/cy.ts @@ -3,6 +3,5 @@ export const cy = { locationsDescription: "Choose a location to see all publications associated with it.", locationsTableHeadingLocation: "Location", locationsTableHeadingPublications: "Number of publications per venue", - locationsError: "We could not load locations. Try again later.", - back: "Back" + locationsError: "We could not load locations. Try again later." }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts index 6d51e58c..390d5e11 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-locations/en.ts @@ -3,6 +3,5 @@ export const en = { locationsDescription: "Choose a location to see all publications associated with it.", locationsTableHeadingLocation: "Location", locationsTableHeadingPublications: "Number of publications per venue", - locationsError: "We could not load locations. Try again later.", - back: "Back" + locationsError: "We could not load locations. Try again later." }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts index d5d8afa9..53b8a73d 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/cy.ts @@ -5,6 +5,5 @@ export const cy = { publicationsTableHeadingListType: "List type", publicationsTableHeadingDisplayFrom: "Display from", publicationsTableHeadingDisplayTo: "Display to", - publicationsError: "We could not load publications for this location.", - back: "Back" + publicationsError: "We could not load publications for this location." }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts index d1e0a121..b7417fba 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/en.ts @@ -5,6 +5,5 @@ export const en = { publicationsTableHeadingListType: "List type", publicationsTableHeadingDisplayFrom: "Display from", publicationsTableHeadingDisplayTo: "Display to", - publicationsError: "We could not load publications for this location.", - back: "Back" + publicationsError: "We could not load publications for this location." }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts index 8fa7d6f3..262bcd5c 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-publications/index.ts @@ -2,33 +2,12 @@ import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getArtefactSummariesByLocation, getArtefactType } from "@hmcts/publication"; import "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; +import { escapeHtml, formatDateTime } from "../../services/formatting.js"; import { cy } from "./cy.js"; import { en } from "./en.js"; const getTranslations = (locale: string) => (locale === "cy" ? cy : en); -function formatDateTime(isoString: string): string { - const date = new Date(isoString); - return date.toLocaleString("en-GB", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -} - -function escapeHtml(text: string): string { - const map: Record = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }; - return text.replace(/[&<>"']/g, (char) => map[char]); -} - const getHandler = async (req: Request, res: Response) => { const locale = (req.query.lng === "cy" ? "cy" : "en") as "en" | "cy"; const t = getTranslations(locale); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts index 67be23ed..5a888885 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/cy.ts @@ -2,6 +2,5 @@ export const cy = { successTitle: "Submission re-submitted", successBanner: "Submission re-submitted.", successNextSteps: "What do you want to do next?", - successLinkToLocations: "Blob explorer – Locations", - back: "Back" + successLinkToLocations: "Blob explorer – Locations" }; diff --git a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts index 3b0daafb..827029b2 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-resubmission-success/en.ts @@ -2,6 +2,5 @@ export const en = { successTitle: "Submission re-submitted", successBanner: "Submission re-submitted.", successNextSteps: "What do you want to do next?", - successLinkToLocations: "Blob explorer – Locations", - back: "Back" + successLinkToLocations: "Blob explorer – Locations" }; diff --git a/libs/system-admin-pages/src/services/formatting.ts b/libs/system-admin-pages/src/services/formatting.ts new file mode 100644 index 00000000..0204ed7f --- /dev/null +++ b/libs/system-admin-pages/src/services/formatting.ts @@ -0,0 +1,21 @@ +export function formatDateTime(isoString: string): string { + const date = new Date(isoString); + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit" + }); +} + +export function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" + }; + return text.replace(/[&<>"']/g, (char) => map[char]); +} diff --git a/libs/system-admin-pages/src/types/session.ts b/libs/system-admin-pages/src/types/session.ts index 93f0d262..a4b589dc 100644 --- a/libs/system-admin-pages/src/types/session.ts +++ b/libs/system-admin-pages/src/types/session.ts @@ -3,5 +3,3 @@ declare module "express-session" { resubmissionArtefactId?: string; } } - -export {}; From 2e870b2e397cd1737c756703c2d452ef832aafcf Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:48:59 +0000 Subject: [PATCH 7/8] VIBE-310 - Fixed formatting comments --- e2e-tests/tests/blob-explorer.spec.ts | 2 +- .../src/repository/service.test.ts | 12 +++---- .../pages/blob-explorer-json-file/index.ts | 2 +- .../src/routes/files/[filename].test.ts | 32 +++++++++++++++++++ .../src/routes/files/[filename].ts | 16 ++++++++-- 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/e2e-tests/tests/blob-explorer.spec.ts b/e2e-tests/tests/blob-explorer.spec.ts index 0ab10fb3..7facfb69 100644 --- a/e2e-tests/tests/blob-explorer.spec.ts +++ b/e2e-tests/tests/blob-explorer.spec.ts @@ -67,7 +67,7 @@ async function createTestPublication(): Promise { // Create a test user and subscription for resubmission test const testUser = await prisma.user.create({ data: { - email: "test-subscriber@example.com", + email: `test-subscriber+${artefactId}@hmcts.net`, userProvenance: "SSO", userProvenanceId: `test-${Date.now()}`, role: "MEDIA", diff --git a/libs/publication/src/repository/service.test.ts b/libs/publication/src/repository/service.test.ts index e77547be..1d2f652d 100644 --- a/libs/publication/src/repository/service.test.ts +++ b/libs/publication/src/repository/service.test.ts @@ -201,7 +201,7 @@ describe("Publication Service", () => { describe("getFlatFileUrl", () => { it("should return file URL when flat file exists", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "other-file.txt"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "other-file.txt"] as string[]); const result = await getFlatFileUrl("test-artefact-id"); @@ -210,7 +210,7 @@ describe("Publication Service", () => { }); it("should return null when no matching file found", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["other-file.pdf", "another-file.txt"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["other-file.pdf", "another-file.txt"] as string[]); const result = await getFlatFileUrl("test-artefact-id"); @@ -226,7 +226,7 @@ describe("Publication Service", () => { }); it("should match file with any extension", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.docx", "other-file.pdf"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.docx", "other-file.pdf"] as string[]); const result = await getFlatFileUrl("test-artefact-id"); @@ -234,7 +234,7 @@ describe("Publication Service", () => { }); it("should return first matching file when multiple exist", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "test-artefact-id.docx"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["test-artefact-id.pdf", "test-artefact-id.docx"] as string[]); const result = await getFlatFileUrl("test-artefact-id"); @@ -256,7 +256,7 @@ describe("Publication Service", () => { }); it("should reject matched filenames with path traversal", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["../../../malicious.pdf", "test-artefact-id.pdf"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["../../../malicious.pdf", "test-artefact-id.pdf"] as string[]); // Use valid artefactId but readdir returns malicious filename const result = await getFlatFileUrl("../../../malicious"); @@ -265,7 +265,7 @@ describe("Publication Service", () => { }); it("should reject matched filenames with directory separators", async () => { - vi.mocked(fs.readdir).mockResolvedValue(["subdir/malicious.pdf"] as any); + vi.mocked(fs.readdir).mockResolvedValue(["subdir/malicious.pdf"] as string[]); const result = await getFlatFileUrl("test-id"); diff --git a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts index 893ccb98..353bf612 100644 --- a/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts +++ b/libs/system-admin-pages/src/pages/blob-explorer-json-file/index.ts @@ -59,7 +59,7 @@ const postHandler = async (req: Request, res: Response) => { // Store artefact ID in session for confirmation page req.session.resubmissionArtefactId = artefactId; - return res.redirect(`/blob-explorer-confirm-resubmission?artefactId=${artefactId}`); + return res.redirect(`/blob-explorer-confirm-resubmission?artefactId=${encodeURIComponent(artefactId)}`); }; export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/routes/files/[filename].test.ts b/libs/system-admin-pages/src/routes/files/[filename].test.ts index 98172c56..16096d99 100644 --- a/libs/system-admin-pages/src/routes/files/[filename].test.ts +++ b/libs/system-admin-pages/src/routes/files/[filename].test.ts @@ -39,6 +39,8 @@ describe("files route", () => { expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "application/pdf"); expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Disposition", 'inline; filename="test-artefact.pdf"'); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, no-cache, must-revalidate, private"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); expect(mockResponse.send).toHaveBeenCalledWith(Buffer.from("fake pdf content")); }); @@ -140,4 +142,34 @@ describe("files route", () => { expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "application/octet-stream"); expect(mockResponse.send).toHaveBeenCalled(); }); + + it("should serve HTML files with attachment disposition to prevent XSS", async () => { + mockRequest.params = { filename: "document.html" }; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("content")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Type", "text/html"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Content-Disposition", 'attachment; filename="document.html"'); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, no-cache, must-revalidate, private"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(mockResponse.send).toHaveBeenCalledWith(Buffer.from("content")); + }); + + it("should set no-cache headers for all file types", async () => { + mockRequest.params = { filename: "data.csv" }; + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("csv,content")); + + const handler = GET[1]; + await handler(mockRequest as Request, mockResponse as Response, vi.fn()); + + expect(mockResponse.setHeader).toHaveBeenCalledWith("Cache-Control", "no-store, no-cache, must-revalidate, private"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Pragma", "no-cache"); + expect(mockResponse.setHeader).toHaveBeenCalledWith("Expires", "0"); + }); }); diff --git a/libs/system-admin-pages/src/routes/files/[filename].ts b/libs/system-admin-pages/src/routes/files/[filename].ts index 00e4003b..f08a70f2 100644 --- a/libs/system-admin-pages/src/routes/files/[filename].ts +++ b/libs/system-admin-pages/src/routes/files/[filename].ts @@ -71,14 +71,26 @@ const getHandler = async (req: Request, res: Response) => { const contentType = contentTypes[ext] || "application/octet-stream"; + // Set cache-control headers to prevent caching of uploaded artefacts + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + + // Prevent MIME sniffing + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Type", contentType); - res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + + // Force download for HTML files to prevent XSS attacks + // For other file types, allow inline viewing + const disposition = ext === ".html" ? "attachment" : "inline"; + res.setHeader("Content-Disposition", `${disposition}; filename="${filename}"`); // Stream the file const fileContent = await fs.readFile(filePath); res.send(fileContent); } catch (error) { - console.error("Error serving file:", error); + console.error("Error serving file"); res.status(404).send("File not found"); } }; From def4f2ca8c14be28f297eeec1ede91615835d38b Mon Sep 17 00:00:00 2001 From: ChrisS1512 <87066931+ChrisS1512@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:25:55 +0000 Subject: [PATCH 8/8] VIBE-310 - Update yarn lock --- yarn.lock | 257 +----------------------------------------------------- 1 file changed, 4 insertions(+), 253 deletions(-) diff --git a/yarn.lock b/yarn.lock index 26e38d8b..2299307e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1119,7 +1119,6 @@ __metadata: "@types/config": "npm:3.3.5" "@types/node": "npm:24.10.1" config: "npm:4.1.1" - vitest: "npm:3.0.4" languageName: unknown linkType: soft @@ -3098,18 +3097,6 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/expect@npm:3.0.4" - dependencies: - "@vitest/spy": "npm:3.0.4" - "@vitest/utils": "npm:3.0.4" - chai: "npm:^5.1.2" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/9e5fe0f905a3f9f39e059d4384785a05bbca34d0f33e8a25ac41e479ce2035a3d86807ee53948a3681a039f751cfad3cd66179a5e691ed815405fe77051b6372 - languageName: node - linkType: hard - "@vitest/expect@npm:4.0.16": version: 4.0.16 resolution: "@vitest/expect@npm:4.0.16" @@ -3124,25 +3111,6 @@ __metadata: languageName: node linkType: hard -"@vitest/mocker@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/mocker@npm:3.0.4" - dependencies: - "@vitest/spy": "npm:3.0.4" - estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - checksum: 10c0/3cf4aaa3516142c826ddc1088f542aab920327caec8b3b0e8d540beef73d4401d160d48592cda3a577a7f6b9ca8480278582d2ff533fe31d7e029c46178da1ac - languageName: node - linkType: hard - "@vitest/mocker@npm:4.0.16": version: 4.0.16 resolution: "@vitest/mocker@npm:4.0.16" @@ -3162,15 +3130,6 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/pretty-format@npm:3.0.4" - dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/a6d12eb454d527be592d98523b11f274be8fc6ee409333731def092a5d36939c68fa5817ae45aa48c5ca23d75f6cc1b76a3db73dff8ee7e28e0932b2ad68b42d - languageName: node - linkType: hard - "@vitest/pretty-format@npm:4.0.16": version: 4.0.16 resolution: "@vitest/pretty-format@npm:4.0.16" @@ -3180,25 +3139,6 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:^3.0.4": - version: 3.2.4 - resolution: "@vitest/pretty-format@npm:3.2.4" - dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 - languageName: node - linkType: hard - -"@vitest/runner@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/runner@npm:3.0.4" - dependencies: - "@vitest/utils": "npm:3.0.4" - pathe: "npm:^2.0.2" - checksum: 10c0/8743c938047c5ee85f3917b917fe4eb9f13c7da911ace96fda92e7f59f15b609a719147fe8ea50089c4ac910668dad013e177d5690c82e68ca043fe72426b055 - languageName: node - linkType: hard - "@vitest/runner@npm:4.0.16": version: 4.0.16 resolution: "@vitest/runner@npm:4.0.16" @@ -3209,17 +3149,6 @@ __metadata: languageName: node linkType: hard -"@vitest/snapshot@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/snapshot@npm:3.0.4" - dependencies: - "@vitest/pretty-format": "npm:3.0.4" - magic-string: "npm:^0.30.17" - pathe: "npm:^2.0.2" - checksum: 10c0/d9a52c1ab42906c712872e42494a538573651e2cc0d1364e0f80f9c9810c48f189740e355c26233458051f88a93b6eaad34df260d792e8ae8e0747170339cc77 - languageName: node - linkType: hard - "@vitest/snapshot@npm:4.0.16": version: 4.0.16 resolution: "@vitest/snapshot@npm:4.0.16" @@ -3231,15 +3160,6 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/spy@npm:3.0.4" - dependencies: - tinyspy: "npm:^3.0.2" - checksum: 10c0/e06490d4bf2245246c578f0bf357157203fe21f7d3c5f3dd984170b2b6ae898cbd1627a0339e64aa8f402df72c9ac908de65e28b8671644dc0b14e1fac9c9a83 - languageName: node - linkType: hard - "@vitest/spy@npm:4.0.16": version: 4.0.16 resolution: "@vitest/spy@npm:4.0.16" @@ -3264,17 +3184,6 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:3.0.4": - version: 3.0.4 - resolution: "@vitest/utils@npm:3.0.4" - dependencies: - "@vitest/pretty-format": "npm:3.0.4" - loupe: "npm:^3.1.2" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/cf36626ec1305d49196360f5bf8237aec8aeacb834927582901c52b7dbfd797abd63074ecff58854f1ddfad7f111987624aded89829561ab9acd9cb51d0672f8 - languageName: node - linkType: hard - "@vitest/utils@npm:4.0.16": version: 4.0.16 resolution: "@vitest/utils@npm:4.0.16" @@ -3496,13 +3405,6 @@ __metadata: languageName: node linkType: hard -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 - languageName: node - linkType: hard - "ast-v8-to-istanbul@npm:^0.3.8": version: 0.3.8 resolution: "ast-v8-to-istanbul@npm:0.3.8" @@ -3778,13 +3680,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 - languageName: node - linkType: hard - "cacache@npm:^19.0.1": version: 19.0.1 resolution: "cacache@npm:19.0.1" @@ -3865,19 +3760,6 @@ __metadata: languageName: unknown linkType: soft -"chai@npm:^5.1.2": - version: 5.3.3 - resolution: "chai@npm:5.3.3" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 - languageName: node - linkType: hard - "chai@npm:^6.2.1": version: 6.2.1 resolution: "chai@npm:6.2.1" @@ -3904,13 +3786,6 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^2.1.1": - version: 2.1.1 - resolution: "check-error@npm:2.1.1" - checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e - languageName: node - linkType: hard - "chokidar@npm:^3.5.2, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -4261,13 +4136,6 @@ __metadata: languageName: node linkType: hard -"deep-eql@npm:^5.0.1": - version: 5.0.2 - resolution: "deep-eql@npm:5.0.2" - checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 - languageName: node - linkType: hard - "deepmerge-ts@npm:7.1.5": version: 7.1.5 resolution: "deepmerge-ts@npm:7.1.5" @@ -4514,7 +4382,7 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.6.0, es-module-lexer@npm:^1.7.0": +"es-module-lexer@npm:^1.7.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b @@ -4774,13 +4642,6 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.1.0": - version: 1.3.0 - resolution: "expect-type@npm:1.3.0" - checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd - languageName: node - linkType: hard - "expect-type@npm:^1.2.2": version: 1.2.2 resolution: "expect-type@npm:1.2.2" @@ -5871,13 +5732,6 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.2": - version: 3.2.1 - resolution: "loupe@npm:3.2.1" - checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 - languageName: node - linkType: hard - "lru-cache@npm:6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -5918,7 +5772,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17, magic-string@npm:^0.30.21": +"magic-string@npm:^0.30.21": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -6594,20 +6448,13 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^2.0.2, pathe@npm:^2.0.3": +"pathe@npm:^2.0.3": version: 2.0.3 resolution: "pathe@npm:2.0.3" checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 languageName: node linkType: hard -"pathval@npm:^2.0.0": - version: 2.0.1 - resolution: "pathval@npm:2.0.1" - checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 - languageName: node - linkType: hard - "pause@npm:0.0.1": version: 0.0.1 resolution: "pause@npm:0.0.1" @@ -7485,7 +7332,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.10.0, std-env@npm:^3.8.0": +"std-env@npm:^3.10.0": version: 3.10.0 resolution: "std-env@npm:3.10.0" checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f @@ -7632,13 +7479,6 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^0.3.2": - version: 0.3.2 - resolution: "tinyexec@npm:0.3.2" - checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 - languageName: node - linkType: hard - "tinyexec@npm:^1.0.1": version: 1.0.1 resolution: "tinyexec@npm:1.0.1" @@ -7673,20 +7513,6 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.0.2": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b - languageName: node - linkType: hard - -"tinyrainbow@npm:^2.0.0": - version: 2.0.0 - resolution: "tinyrainbow@npm:2.0.0" - checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f - languageName: node - linkType: hard - "tinyrainbow@npm:^3.0.3": version: 3.0.3 resolution: "tinyrainbow@npm:3.0.3" @@ -7694,13 +7520,6 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^3.0.2": - version: 3.0.2 - resolution: "tinyspy@npm:3.0.2" - checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 - languageName: node - linkType: hard - "tmp@npm:^0.2.0": version: 0.2.5 resolution: "tmp@npm:0.2.5" @@ -8040,21 +7859,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.0.4": - version: 3.0.4 - resolution: "vite-node@npm:3.0.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.0" - es-module-lexer: "npm:^1.6.0" - pathe: "npm:^2.0.2" - vite: "npm:^5.0.0 || ^6.0.0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/8e644ad1c5dd29493314866ca9ec98779ca4e7ef4f93d89d7377b8cae6dd89315908de593a20ee5d3e0b44cb14b1e0ce6a8a39c6a3a7143c28ab9a7965b54397 - languageName: node - linkType: hard - "vite-plugin-static-copy@npm:3.1.4": version: 3.1.4 resolution: "vite-plugin-static-copy@npm:3.1.4" @@ -8124,59 +7928,6 @@ __metadata: languageName: node linkType: hard -"vitest@npm:3.0.4": - version: 3.0.4 - resolution: "vitest@npm:3.0.4" - dependencies: - "@vitest/expect": "npm:3.0.4" - "@vitest/mocker": "npm:3.0.4" - "@vitest/pretty-format": "npm:^3.0.4" - "@vitest/runner": "npm:3.0.4" - "@vitest/snapshot": "npm:3.0.4" - "@vitest/spy": "npm:3.0.4" - "@vitest/utils": "npm:3.0.4" - chai: "npm:^5.1.2" - debug: "npm:^4.4.0" - expect-type: "npm:^1.1.0" - magic-string: "npm:^0.30.17" - pathe: "npm:^2.0.2" - std-env: "npm:^3.8.0" - tinybench: "npm:^2.9.0" - tinyexec: "npm:^0.3.2" - tinypool: "npm:^1.0.2" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0" - vite-node: "npm:3.0.4" - why-is-node-running: "npm:^2.3.0" - peerDependencies: - "@edge-runtime/vm": "*" - "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.0.4 - "@vitest/ui": 3.0.4 - happy-dom: "*" - jsdom: "*" - peerDependenciesMeta: - "@edge-runtime/vm": - optional: true - "@types/debug": - optional: true - "@types/node": - optional: true - "@vitest/browser": - optional: true - "@vitest/ui": - optional: true - happy-dom: - optional: true - jsdom: - optional: true - bin: - vitest: vitest.mjs - checksum: 10c0/b610df2b9ed285c5e9a20014f277e1aab84513be71ab51a99e1091b6769aa95323e0c76eb7410b91ed6094566949a921716a672c3b746aeae9d5184b323fb6c0 - languageName: node - linkType: hard - "vitest@npm:4.0.16": version: 4.0.16 resolution: "vitest@npm:4.0.16"