diff --git a/devops-pipelines/backend/backend-dev-build-and-deploy.yml b/devops-pipelines/backend/backend-dev-build-and-deploy.yml index 1c47e39f..ae917db6 100644 --- a/devops-pipelines/backend/backend-dev-build-and-deploy.yml +++ b/devops-pipelines/backend/backend-dev-build-and-deploy.yml @@ -38,15 +38,3 @@ stages: dependsOn: "Build" environment: "dev" buildArtifact: $(buildArtifact) - - - stage: Run_Postman_Tests_dev - displayName: "Dev - Run Postman Tests" - dependsOn: "Deploy_Backend_dev" # final stage of /templates/backend-deploy-stages.yml - pool: "CMRC PreProd Pool" - jobs: - - template: ../templates/postman-test-job.yml - parameters: - appName: "fa-cmrc-api-dev" - environment: "dev" - postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json" - nodeVersion: "24.x" diff --git a/devops-pipelines/backend/backend-pr-build-and-test.yml b/devops-pipelines/backend/backend-pr-build-and-test.yml index 615d379f..7c6d0481 100644 --- a/devops-pipelines/backend/backend-pr-build-and-test.yml +++ b/devops-pipelines/backend/backend-pr-build-and-test.yml @@ -11,25 +11,49 @@ pr: - src/tests/* variables: - buildConfiguration: 'Release' - dotNetVersion: '10.x' - testResultsDirectory: '$(Agent.TempDirectory)/TestResults' + - name: prSourceBranch + value: $[variables['System.PullRequest.SourceBranch']] + - name: prTargetBranch + value: $[variables['System.PullRequest.TargetBranch']] -pool: 'CMRC PreProd Pool' +pool: "CMRC PreProd Pool" stages: - stage: Build_And_Test_Backend - displayName: 'Build, Test & Package' + condition: not(and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main') + )) + displayName: "Build, Test & Package" + variables: + buildConfiguration: "Release" + dotNetVersion: "10.x" + testResultsDirectory: "$(Agent.TempDirectory)/TestResults" jobs: - job: Build_And_Test_Solution - displayName: 'Build & Test Solution' + displayName: "Build & Test Solution" steps: - template: ../templates/dotnet-build-steps.yml parameters: - projectPath: 'src/backend/Cps.CaseManagement.sln' - projectName: 'Backend Solution' + projectPath: "src/backend/Cps.CaseManagement.sln" + projectName: "Backend Solution" dotNetVersion: $(dotNetVersion) publishOutput: false runTests: true checkFormatting: true testResultsDirectory: $(testResultsDirectory) + + - stage: Run_Postman_Tests + condition: and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main' ) + ) + dependsOn: [] + displayName: "Run Postman Tests on Dev" + jobs: + - template: ../templates/postman-test-job.yml + parameters: + appName: "fa-cmrc-api-dev" + environment: "dev" + postmanCollectionPath: "$(System.DefaultWorkingDirectory)/src/backend/postman/RegisterCaseAPITests.postman_collection.json" + nodeVersion: "24.x" diff --git a/devops-pipelines/ui/ui-pr-build-and-test.yml b/devops-pipelines/ui/ui-pr-build-and-test.yml index fc95665f..b84385e1 100644 --- a/devops-pipelines/ui/ui-pr-build-and-test.yml +++ b/devops-pipelines/ui/ui-pr-build-and-test.yml @@ -12,16 +12,25 @@ pr: pool: 'CMRC PreProd Pool' variables: - - name: nodeVersion - value: '24.x' - - name: workingDir - value: src/ui-spa - - name: covDir - value: 'coverage' + - name: prSourceBranch + value: $[variables['System.PullRequest.SourceBranch']] + - name: prTargetBranch + value: $[variables['System.PullRequest.TargetBranch']] stages: - stage: Build_and_Test_UI + condition: not(and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main') + )) displayName: 'Build & Test UI SPA' + variables: + - name: nodeVersion + value: '24.x' + - name: workingDir + value: src/ui-spa + - name: covDir + value: 'coverage' jobs: - job: Build_and_Test displayName: 'Build & Test App' @@ -148,3 +157,16 @@ stages: inputs: summaryFileLocation: '$(workingDir)/$(covDir)/merged/Cobertura.xml' failIfCoverageEmpty: true + + # On PR development -> main, run the E2E tests in dev + - stage: Run_E2E_tests + condition: and( + eq(variables.prSourceBranch, 'development' ), + eq(variables.prTargetBranch, 'main' ) + ) + dependsOn: [] + displayName: "Run E2E Test on Dev" + jobs: + - template: ../templates/e2e-playwright-test-job.yml + parameters: + environment: "dev" diff --git a/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts b/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts new file mode 100644 index 00000000..bcbd2eba --- /dev/null +++ b/src/ui-spa/e2e-tests/case-registered-journey-id.spec.ts @@ -0,0 +1,76 @@ +import { expect, test, type Request } from "@playwright/test"; +import { generateUniqueUrn } from "./utils/generateUrn"; +import { + AREA, + startAtHomePage, + enterAreasAndCaseDetails, + completeAssigneeAndSubmit, +} from "./journeys/steps"; + +const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck"; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +interface TelemetryPayload { + telemetryType: string; + properties: Record[]; +} + +const toProperties = (payload: TelemetryPayload): Record => + Object.assign({}, ...payload.properties); + +test("Scenario 25: the case registration carries the journey id of the JourneyStarted event and the chosen area", async ({ + page, +}) => { + const telemetry: TelemetryPayload[] = []; + page.on("request", (request: Request) => { + if ( + request.url().endsWith("/api/v1/telemetry") && + request.method() === "POST" + ) { + telemetry.push(JSON.parse(request.postData() ?? "null")); + } + }); + + const urn = generateUniqueUrn(); + await startAtHomePage(page, { + operationName: OPERATION_NAME, + hasSuspect: false, + }); + await enterAreasAndCaseDetails(page, urn); + + const casesRequest = page.waitForRequest( + (request) => + request.url().endsWith("/api/v1/cases") && request.method() === "POST", + { timeout: 120_000 }, + ); + await completeAssigneeAndSubmit(page, urn, OPERATION_NAME); + + const journeyStarted = telemetry + .map(toProperties) + .filter((properties) => properties.name === "JourneyStarted"); + expect( + journeyStarted, + "expected exactly one JourneyStarted event", + ).toHaveLength(1); + const journeyId = journeyStarted[0].journeyId; + expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID); + + const pageViews = telemetry + .filter((payload) => payload.telemetryType === "PageView") + .map(toProperties); + expect(pageViews.length, "no page views were tracked").toBeGreaterThan(0); + for (const pageView of pageViews) { + expect(pageView.journeyId, `page view ${pageView.pageName}`).toBe( + journeyId, + ); + } + + const payload = JSON.parse((await casesRequest).postData() ?? "null") as { + journeyId?: string; + areaOrDivisionText?: string; + } | null; + expect(payload, "no POST /api/v1/cases payload was captured").not.toBeNull(); + expect(payload!.journeyId).toBe(journeyId); + expect(payload!.areaOrDivisionText).toBe(AREA); +}); diff --git a/src/ui-spa/e2e-tests/journey-tracking.spec.ts b/src/ui-spa/e2e-tests/journey-tracking.spec.ts new file mode 100644 index 00000000..62eed757 --- /dev/null +++ b/src/ui-spa/e2e-tests/journey-tracking.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Request } from "@playwright/test"; +import { CaseAreasPage } from "../integration-tests/pages/caseAreasPage"; +import { CancelCaseRegistrationConfirmationPage } from "../integration-tests/pages/cancelCaseRegistrationConfirmationPage"; +import { AREA, startAtHomePage } from "./journeys/steps"; +import { expectStep } from "./utils/expectStep"; + +const OPERATION_NAME = process.env.E2E_OPERATION_NAME ?? "thunderstruck"; +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +type Properties = Record; + +interface TelemetryCall { + telemetryType: string; + properties: Properties; +} + +const toCall = (request: Request): TelemetryCall => { + const postData = request.postData(); + expect(postData, "telemetry POST was sent without a body").toBeTruthy(); + const body = JSON.parse(postData!) as { + telemetryType?: string; + properties?: Properties[]; + } | null; + expect( + Array.isArray(body?.properties), + `telemetry POST has no properties array: ${postData}`, + ).toBe(true); + return { + telemetryType: body!.telemetryType ?? "", + properties: Object.assign({}, ...body!.properties!), + }; +}; + +test("Scenario 26: the journey starts past the first screen, tracks each step and logs the cancellation", async ({ + page, +}) => { + const requests: Request[] = []; + page.on("request", (request) => { + if ( + request.url().endsWith("/api/v1/telemetry") && + request.method() === "POST" + ) { + requests.push(request); + } + }); + const telemetry = () => requests.map(toCall); + const events = (name: string) => + telemetry().filter( + (call) => call.telemetryType === "Event" && call.properties.name === name, + ); + const pageViews = () => + telemetry().filter((call) => call.telemetryType === "PageView"); + + await startAtHomePage(page, { + operationName: OPERATION_NAME, + hasSuspect: false, + }); + await expectStep(page, "/case-registration/areas"); + + await expect.poll(() => events("JourneyStarted").length).toBe(1); + const journeyId = events("JourneyStarted")[0].properties.journeyId; + expect(journeyId, "JourneyStarted journeyId is not a v4 UUID").toMatch(UUID); + + const areasPage = new CaseAreasPage(page); + await areasPage.enterAreaOrDivision(AREA); + await areasPage.saveAndContinue(); + await expectStep(page, "/case-registration/case-details"); + + await page.getByRole("link", { name: "Cancel" }).click(); + await expectStep( + page, + "/case-registration/cancel-case-registration-confirmation", + ); + + const expectedPageViews = [ + { pageName: "Home", path: "/case-registration" }, + { pageName: "Case Areas", path: "/case-registration/areas" }, + { pageName: "Case Details", path: "/case-registration/case-details" }, + { + pageName: "Cancel Case Registration Confirmation", + path: "/case-registration/cancel-case-registration-confirmation", + }, + ]; + const byPath = (a: Properties, b: Properties) => + String(a.path).localeCompare(String(b.path)); + await expect.poll(() => pageViews().length).toBe(expectedPageViews.length); + expect( + pageViews() + .map(({ properties }) => properties) + .sort(byPath), + "page views missing, duplicated, or carrying the wrong journeyId", + ).toEqual( + expectedPageViews.map((view) => ({ ...view, journeyId })).sort(byPath), + ); + + const cancelPage = new CancelCaseRegistrationConfirmationPage(page); + await cancelPage.selectCancelCaseRegistrationYes(); + const cancelledResponse = page.waitForResponse( + (response) => + response.url().endsWith("/api/v1/telemetry") && + response.request().postData()?.includes("JourneyCancelled") === true, + ); + await cancelPage.continue(); + + await expect.poll(() => events("JourneyCancelled").length).toBe(1); + expect(events("JourneyCancelled")[0].properties).toEqual({ + name: "JourneyCancelled", + journeyId, + cancelledFrom: "/case-registration/case-details", + }); + const response = await cancelledResponse; + expect( + response.ok(), + `JourneyCancelled telemetry returned ${response.status()}`, + ).toBe(true); + + expect( + events("JourneyStarted"), + "the journey was started twice", + ).toHaveLength(1); +}); diff --git a/src/ui-spa/e2e-tests/journeys/longPathValidation.ts b/src/ui-spa/e2e-tests/journeys/longPathValidation.ts index e162409a..abb9607e 100644 --- a/src/ui-spa/e2e-tests/journeys/longPathValidation.ts +++ b/src/ui-spa/e2e-tests/journeys/longPathValidation.ts @@ -146,7 +146,7 @@ export async function completeLongPathValidation( await caseDetailsPage.enterWitnessCareUnit(WITNESS_CARE_UNIT); await caseDetailsPage.submitAndExpectExistingUrnError(existingUrn); await caseDetailsPage.enterUrn(urn); - await caseDetailsPage.saveAndContinue(); + await caseDetailsPage.saveAndContinueWithFreeUrn(urn); // Add suspect. const addSuspectPage = new AddSuspectPage(page); diff --git a/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts b/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts index 84fe2e18..745b9392 100644 --- a/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts +++ b/src/ui-spa/e2e-tests/journeys/shortPathDuplicateUrn.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { CaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; +import { CaseDetailsPage } from "../pages/caseDetailsPage"; import { generateUniqueUrn } from "../utils/generateUrn"; import { expectStep } from "../utils/expectStep"; import { @@ -28,12 +28,17 @@ export async function completeShortPathDuplicateUrn( freeUrn = generateUniqueUrn(); } + // Registering this first case is what makes the URN "existing"; if the + // generated reference was already taken, enterAreasAndCaseDetails moves + // existingUrn on to a free one, which the second visit below then reuses. await startAtHomePage(page, { operationName, hasSuspect: false }); await enterAreasAndCaseDetails(page, existingUrn); await completeAssigneeAndSubmit(page, existingUrn, operationName); await startAtHomePage(page, { operationName, hasSuspect: false }); - await enterAreasAndCaseDetails(page, existingUrn); + await enterAreasAndCaseDetails(page, existingUrn, { + expectDuplicateUrn: true, + }); const detailsPage = new CaseDetailsPage(page); await expectStep(page, "/case-registration/case-details"); @@ -42,7 +47,7 @@ export async function completeShortPathDuplicateUrn( "URN already exists, please change reference text and try again", ); await detailsPage.enterUrnUniqueReference(freeUrn.uniqueReference); - await detailsPage.saveAndContinue(); + await detailsPage.saveAndContinueWithFreeUrn(freeUrn); await completeMonitoringAndAssignee(page); await verifySummaryAndSubmit(page, freeUrn, { diff --git a/src/ui-spa/e2e-tests/journeys/steps.ts b/src/ui-spa/e2e-tests/journeys/steps.ts index bb0ceefb..5e1f5cfb 100644 --- a/src/ui-spa/e2e-tests/journeys/steps.ts +++ b/src/ui-spa/e2e-tests/journeys/steps.ts @@ -1,7 +1,7 @@ import { expect, type Page } from "@playwright/test"; import { CaseRegistrationHomePage } from "../../integration-tests/pages/caseRegistrationHomePage"; import { CaseAreasPage } from "../../integration-tests/pages/caseAreasPage"; -import { CaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; +import { CaseDetailsPage } from "../pages/caseDetailsPage"; import { CaseMonitoringPage } from "../../integration-tests/pages/caseMonitoringPage"; import { CaseAssigneePage } from "../../integration-tests/pages/caseAssigneePage"; import { CaseRegistrationSummaryPage } from "../../integration-tests/pages/caseRegistrationSummaryPage"; @@ -51,9 +51,16 @@ export async function startAtHomePage( await homePage.saveAndContinue(); } +export interface CaseDetailsOptions { + // Set when the caller has deliberately picked an already-registered URN and + // wants to assert the error itself, instead of moving on to a free one. + expectDuplicateUrn?: boolean; +} + export async function enterAreasAndCaseDetails( page: Page, urn: UrnParts, + { expectDuplicateUrn = false }: CaseDetailsOptions = {}, ): Promise { const areasPage = new CaseAreasPage(page); await expectStep(page, "/case-registration/areas"); @@ -62,13 +69,18 @@ export async function enterAreasAndCaseDetails( const detailsPage = new CaseDetailsPage(page); await expectStep(page, "/case-registration/case-details"); - await detailsPage.enterUrnPoliceForce(urn.policeForce); - await detailsPage.enterUrnPoliceUnit(urn.policeUnit); - await detailsPage.enterUrnUniqueReference(urn.uniqueReference); - await detailsPage.enterUrnYearReference(urn.yearReference); + await detailsPage.enterUrn(urn); await detailsPage.enterRegisteringUnit(REGISTERING_UNIT); await detailsPage.enterWitnessCareUnit(WITNESS_CARE_UNIT); - await detailsPage.saveAndContinue(); + + if (expectDuplicateUrn) { + await detailsPage.saveAndContinue(); + return; + } + + // Replaces the URN in place if the generated reference is already taken, so + // the journey carries on with the URN the case is actually registered under. + await detailsPage.saveAndContinueWithFreeUrn(urn); } export function watchAssigneeLookups(page: Page) { diff --git a/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts b/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts new file mode 100644 index 00000000..fc32f0f9 --- /dev/null +++ b/src/ui-spa/e2e-tests/offence-results-actions-column.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; +import { ChargesOffenceSearchPage } from "./pages/chargesOffenceSearchPage"; +import { generateUniqueUrn } from "./utils/generateUrn"; +import { expectStep } from "./utils/expectStep"; +import { + startSingleSuspectUpToCharges, + chargeDates, +} from "./journeys/suspectChargeSteps"; + +const OFFENCE_CODE = process.env.E2E_OFFENCE_CODE ?? "TH68040"; + +test("Scenario 21: the Add action is the first column of the offence search results", async ({ + page, +}) => { + const urn = generateUniqueUrn(); + const { arrestDate } = chargeDates(); + + await startSingleSuspectUpToCharges(page, { urn, arrestDate }); + + const offenceSearchPage = new ChargesOffenceSearchPage(page); + await expectStep( + page, + "/case-registration/suspect-0/charge-0/charges-offence-search", + ); + await offenceSearchPage.searchAndVerifyActionsColumnIsFirst(OFFENCE_CODE); +}); diff --git a/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts b/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts index 47b52c60..5873e5ba 100644 --- a/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts +++ b/src/ui-spa/e2e-tests/pages/caseDetailsPage.ts @@ -1,10 +1,16 @@ import { type Page, expect } from "@playwright/test"; import { CaseDetailsPage as IntegrationCaseDetailsPage } from "../../integration-tests/pages/caseDetailsPage"; -import { type UrnParts } from "../utils/generateUrn"; +import { refreshUniqueReference, type UrnParts } from "../utils/generateUrn"; const EXISTING_URN_ERROR = "URN already exists, please change reference text and try again"; +// A collision only costs one extra submit, so a handful of attempts is far more +// than the odds need; the cap is here to fail loudly rather than spin. +const MAX_URN_ATTEMPTS = 5; + +const URN_EXISTS_ENDPOINT = /\/api\/v1\/urns\/.+\/exists/; + // Reuses the integration case-details page object (identical selectors) and adds // an e2e-only assertion for the backend duplicate-URN check, which the MSW suite // can't exercise (its /urns/:urn/exists handler always returns false). @@ -23,14 +29,44 @@ export class CaseDetailsPage extends IntegrationCaseDetailsPage { await this.enterUrnYearReference(urn.yearReference); } - // Assumes the registering unit and WCU are already valid (the backend URN - // existence check only runs once client-side validation passes). Enters an - // already-registered URN, submits, and asserts the real error rendered from - // GET /api/v1/urns/{urn}/exists returning true. + // Submits case details, and if the URN turns out to be taken, swaps in a new + // reference and submits again. + // + // The generated reference collides with an already-registered case often + // enough to flake the suite. Rather than guessing from the rendered error, we + // read the boolean the page itself acts on: GET /api/v1/urns/{urn}/exists, + // which the page calls on every submit once client-side validation passes. + // That avoids mistaking a stale error summary from the previous attempt for a + // fresh collision, and it settles as soon as the backend answers. + // + // Assumes the registering unit and WCU are already filled in, since the + // existence check only runs after client-side validation passes. + async saveAndContinueWithFreeUrn(urn: UrnParts): Promise { + for (let attempt = 1; attempt <= MAX_URN_ATTEMPTS; attempt++) { + const existsResponse = this.currentPage.waitForResponse( + (r) => URN_EXISTS_ENDPOINT.test(r.url()) && r.ok(), + { timeout: 30_000 }, + ); + await this.saveAndContinue(); + const urnExists = (await (await existsResponse).json()) as boolean; + + if (!urnExists) return urn; + + refreshUniqueReference(urn); + await this.enterUrnUniqueReference(urn.uniqueReference); + } + + throw new Error( + `URN was already registered on ${MAX_URN_ATTEMPTS} consecutive attempts; last tried ${urn.formatted}`, + ); + } + + // Enters an already-registered URN, submits, and asserts the real error + // rendered from GET /api/v1/urns/{urn}/exists returning true. async submitAndExpectExistingUrnError(existingUrn: UrnParts) { await this.enterUrn(existingUrn); const existsResponse = this.currentPage.waitForResponse( - (r) => /\/api\/v1\/urns\/.+\/exists/.test(r.url()) && r.ok(), + (r) => URN_EXISTS_ENDPOINT.test(r.url()) && r.ok(), { timeout: 30_000 }, ); await this.saveAndContinue(); diff --git a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts index b02d23f3..26f5fdc1 100644 --- a/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts +++ b/src/ui-spa/e2e-tests/pages/chargesOffenceSearchPage.ts @@ -3,6 +3,14 @@ import { ChargesOffenceSearchPagePage as IntegrationChargesOffenceSearchPage } f const REPEALED_TAG_PREFIX = "offence-repealed-warning-"; +const RESULTS_COLUMNS = [ + "Actions", + "CJS code", + "Description", + "Statute name and section", + "Effective dates", +]; + interface Offence { code: string; description: string; @@ -50,6 +58,26 @@ export class ChargesOffenceSearchPage extends IntegrationChargesOffenceSearchPag ).toBeVisible(); } + async searchAndVerifyActionsColumnIsFirst( + offenceCode: string, + ): Promise { + await this.submitSearch(offenceCode); + + const resultsTable = this.resultsWrapper().getByRole("table"); + await expect(resultsTable).toBeVisible(); + + await expect(resultsTable.locator("th")).toHaveText(RESULTS_COLUMNS); + + const firstRowCells = resultsTable + .locator("tbody tr") + .first() + .locator("td"); + await expect( + firstRowCells.first().getByRole("link", { name: "Add" }), + ).toBeVisible(); + await expect(firstRowCells.nth(1)).toHaveText(offenceCode); + } + async searchAndAddFirstOffence(offenceCode: string): Promise { await this.submitSearch(offenceCode); await expect( diff --git a/src/ui-spa/e2e-tests/unauthorised-page.spec.ts b/src/ui-spa/e2e-tests/unauthorised-page.spec.ts new file mode 100644 index 00000000..290bd279 --- /dev/null +++ b/src/ui-spa/e2e-tests/unauthorised-page.spec.ts @@ -0,0 +1,49 @@ +import { expect, test, type Page } from "@playwright/test"; +import { API_BASE_URL } from "./config"; +import { expectStep } from "./utils/expectStep"; + +const CMS_AUTH_COOKIE = "Cms-Auth-Values"; + +const expectUnauthorisedPage = async (page: Page): Promise => { + const unitsResponse = page.waitForResponse( + (r) => r.url().includes("/api/v1/units") && r.request().method() === "GET", + { timeout: 60_000 }, + ); + await page.goto("/"); + expect((await unitsResponse).status()).toBe(401); + + await expectStep(page, "/unauthorised"); + await expect(page.locator("h1")).toHaveText("You cannot access this service"); + await expect( + page.getByText( + "Close this tab and open the homepage again from CMS Classic.", + ), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Register a case" }), + ).toHaveCount(0); +}; + +test("Scenario 23: a user without the CMS auth cookie is redirected to the unauthorised page", async ({ + page, + context, +}) => { + await context.clearCookies({ name: CMS_AUTH_COOKIE }); + + await expectUnauthorisedPage(page); +}); + +test("Scenario 24: a user with an invalid CMS auth cookie is redirected to the unauthorised page", async ({ + page, + context, +}) => { + const cmsCookie = (await context.cookies(`${API_BASE_URL}/api/`)).find( + (cookie) => cookie.name === CMS_AUTH_COOKIE, + ); + if (!cmsCookie) { + throw new Error(`${CMS_AUTH_COOKIE} missing from the saved storage state`); + } + await context.addCookies([{ ...cmsCookie, value: "invalid" }]); + + await expectUnauthorisedPage(page); +}); diff --git a/src/ui-spa/e2e-tests/utils/generateUrn.ts b/src/ui-spa/e2e-tests/utils/generateUrn.ts index 233a3ee0..91304dd4 100644 --- a/src/ui-spa/e2e-tests/utils/generateUrn.ts +++ b/src/ui-spa/e2e-tests/utils/generateUrn.ts @@ -8,20 +8,40 @@ export interface UrnParts { formatted: string; } +// The police force, unit and year are fixed, so the 5-digit reference is the +// only thing keeping a URN unique - a space of 100,000 shared with every case +// already registered in the environment. Collisions are therefore expected +// rather than rare, and are resolved on submit by +// CaseDetailsPage.saveAndContinueWithFreeUrn. +const nextUniqueReference = (): string => { + const workerIndex = Number(process.env.TEST_WORKER_INDEX ?? "0"); + return String( + (Date.now() + workerIndex * 13 + randomInt(100000)) % 100000, + ).padStart(5, "0"); +}; + +const formatUrn = (parts: Omit): string => + `${parts.policeForce}${parts.policeUnit}${parts.uniqueReference}${parts.yearReference}`; + export const generateUniqueUrn = ( policeForce = "12", policeUnit = "21", year = "26", ): UrnParts => { - const workerIndex = Number(process.env.TEST_WORKER_INDEX ?? "0"); - const uniqueReference = String( - (Date.now() + workerIndex * 13 + randomInt(100000)) % 100000, - ).padStart(5, "0"); - return { + const parts = { policeForce, policeUnit, - uniqueReference, + uniqueReference: nextUniqueReference(), yearReference: year, - formatted: `${policeForce}${policeUnit}${uniqueReference}${year}`, }; + return { ...parts, formatted: formatUrn(parts) }; +}; + +// Swaps in a new reference in place, so callers holding the object (summary and +// confirmation assertions, later steps) see the URN the case was registered +// under rather than the one that turned out to be taken. +export const refreshUniqueReference = (urn: UrnParts): UrnParts => { + urn.uniqueReference = nextUniqueReference(); + urn.formatted = formatUrn(urn); + return urn; };