From 991881108d1dd4299e8293bc867816bba3e3954c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 10:33:27 +0000 Subject: [PATCH] chore: test fixes --- docker-compose.e2e.yaml | 85 ++++++++++- .../cypress/e2e/flows/booking_cancel.cy.ts | 5 +- .../e2e/flows/bulk_critical_matrix.cy.ts | 11 +- .../e2e/flows/bulk_export_full_train.cy.ts | 3 +- .../e2e/flows/bulk_import_full_train.cy.ts | 3 +- .../e2e/flows/bulk_import_split_promote.cy.ts | 4 +- .../e2e/flows/contract-lifecycle.cy.ts | 3 +- .../cypress/e2e/flows/export_full_train.cy.ts | 3 +- .../cypress/e2e/flows/export_one_time.cy.ts | 19 ++- e2e/freight/cypress/e2e/flows/import-utils.ts | 138 +++++++++++++++--- .../cypress/e2e/flows/import_full_train.cy.ts | 3 +- .../e2e/flows/import_split_promote.cy.ts | 4 +- .../e2e/flows/intercity_one_time.cy.ts | 33 ++--- .../e2e/flows/mixed_export_full_train.cy.ts | 3 +- .../e2e/flows/mixed_import_full_train.cy.ts | 3 +- .../flows/mixed_import_split_promote.cy.ts | 5 +- .../cypress/e2e/flows/onboarding.cy.ts | 55 ++++--- e2e/freight/cypress/support/commands.ts | 63 +++++--- e2e/freight/etrade-mock/server.js | 124 ++++++++++++++++ e2e/freight/fayda-mock/server.js | 52 +++++++ e2e/freight/scripts/e2e.mjs | 21 +++ 21 files changed, 544 insertions(+), 96 deletions(-) create mode 100644 e2e/freight/etrade-mock/server.js create mode 100644 e2e/freight/fayda-mock/server.js diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index b00402761..5ce7702c3 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -61,6 +61,71 @@ services: - mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc restart: "no" + # Stand-in for eSignet's token/userinfo endpoints (see fayda-mock/server.js). + # The real Fayda authorization step (phone + SMS OTP) can't run in e2e — + # Cypress bypasses the popup and drives POST start / POST complete directly, + # so this only needs to answer the token exchange + userinfo calls that + # `completeVerification` makes server-side. + fayda-mock-e2e: + image: node:20-alpine + volumes: + - ./e2e/freight/fayda-mock:/app:ro + working_dir: /app + command: ["node", "server.js"] + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:4400/userinfo').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 3s + timeout: 3s + retries: 10 + + # Stand-in for https://etrade.gov.et — ETradeService's base URL is + # hardcoded (not env-configurable like Fayda's endpoints), so this is + # reached by DNS alias instead: the "etrade.gov.et" network alias below + # makes freight-api-e2e's real hardcoded requests land here. See + # etrade-mock/server.js. + etrade-mock-e2e: + # Debian-based (not -alpine): ships openssl in the base image, so the + # self-signed cert below needs no package install at container start + # (alpine's apk would have to fetch it from the network every boot). + image: node:20 + volumes: + - ./e2e/freight/etrade-mock:/app:ro + working_dir: /app + environment: + TLS_CERT_PATH: /tmp/etrade-mock/cert.pem + TLS_KEY_PATH: /tmp/etrade-mock/key.pem + # Throwaway self-signed cert generated fresh into /tmp on every + # container start — nothing shaped like a key/cert is committed (the + # code mount above is :ro, so this couldn't write there anyway). + command: + - sh + - -c + - > + mkdir -p /tmp/etrade-mock && + openssl req -x509 -newkey rsa:2048 -keyout "$$TLS_KEY_PATH" -out "$$TLS_CERT_PATH" -days 1 -nodes -subj "/CN=etrade.gov.et" 2>/dev/null && + node server.js + networks: + default: + aliases: + - etrade.gov.et + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('https').get({hostname:'localhost',port:443,path:'/api/BusinessMain/GetBusinessByLicenseNo?LicenseNo=x&Tin=x&Lang=en',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))", + ] + interval: 3s + timeout: 3s + retries: 10 + freight-api-e2e: build: context: . @@ -74,6 +139,10 @@ services: condition: service_healthy minio-init-e2e: condition: service_completed_successfully + fayda-mock-e2e: + condition: service_healthy + etrade-mock-e2e: + condition: service_healthy environment: PORT: "3001" DB_HOST: postgres-freight-e2e @@ -104,7 +173,21 @@ services: MINIO_REGION: us-east-1 # External integrations off RABBITMQ_ENABLED: "false" - FAYDA_ENABLED: "false" + # Fayda ON, pointed at the local mock (fayda-mock-e2e) instead of the + # real eSignet infra — see that service's comment above. Cypress drives + # verification via the API (start + complete), never the real popup. + FAYDA_ENABLED: "true" + FAYDA_CLIENT_ID: e2e-fayda-client + FAYDA_AUTHORIZATION_ENDPOINT: http://fayda-mock-e2e:4400/authorize + FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token + FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo + FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback + FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback + # Throwaway e2e-only RSA JWK (client_assertion signing) — the mock + # never verifies the signature, this just has to be well-formed. + # Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64), + # not committed here, so nothing shaped like a private key sits in git. + FAYDA_PRIVATE_KEY_BASE64: ${FAYDA_PRIVATE_KEY_BASE64} # SMS strategy has no kill switch and defaults to a real dev endpoint — # blackhole it so e2e never sends SMS (failures are logged, non-fatal). OZIKING_SMS_URL: http://127.0.0.1:9/sms diff --git a/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts index 9eba43831..1f7c14045 100644 --- a/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts +++ b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts @@ -20,6 +20,7 @@ import { acceptExport, apiPost, bookBulk, + clearToOperationRequestPending, createImportSchedule, db, departureAt, @@ -83,8 +84,9 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0 it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => { bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending("CXA", BOOKING_DAY); - // Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING. + // Cleared non-customs export booking sits at OPERATION_REQUEST_PENDING. withBooking("CXA", (b) => { expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING"); cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201])); @@ -112,6 +114,7 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0 it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => { bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending("CXB", BOOKING_DAY); acceptExport("CXB"); markPaid("CXB"); pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons diff --git a/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts index 25213bcfb..1b6a5085c 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts @@ -19,6 +19,8 @@ import { acceptOperation, apiPost, bookBulk, + clearIntercityToFullyExecuted, + clearToOperationRequestPending, closeBookingWindow, completeDocReview, createImportSchedule, @@ -98,8 +100,10 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => { bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending("BM1", BOOKING_DAY); acceptOperation("BM1"); bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending("BMSUB", BOOKING_DAY); acceptOperation("BMSUB"); withSchedule(DEPARTURE, (s) => { @@ -122,7 +126,9 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => { bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless - acceptOperation("BMIC"); + // DOMESTIC bookings skip the shipment-day step entirely — finalize alone + // lands FULLY_EXECUTED; staff assign it to a passing train below. + clearIntercityToFullyExecuted("BMIC"); withSchedule(DEPARTURE, (s) => { withBooking("BMIC", (b) => { @@ -159,6 +165,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45)); bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY }); + clearToOperationRequestPending("BMG", GIANT_DAY); acceptOperation("BMG"); withSchedule(GIANT_DEPARTURE, (s) => { closeBookingWindow(s.id); @@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train expectFailure: "must take the whole", }); bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY }); - pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending("BMG", REMAINDER_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts index bb8e30b55..330639ec7 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts @@ -19,6 +19,7 @@ import { acceptExport, apiPost, bookBulk, + clearToOperationRequestPending, completeBookingMilestone, createImportSchedule, db, @@ -103,7 +104,7 @@ describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", { it("six exporters book wheat inside the one window", () => { BOOKINGS.forEach((b) => { bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts index 0ffdb37f3..b7ff74a8e 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts @@ -20,6 +20,7 @@ import { acceptOperation, apiPost, bookBulk, + clearToOperationRequestPending, closeBookingWindow, completeBookingMilestone, completeDocReview, @@ -103,7 +104,7 @@ describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retrie it("customer books all six wheat shipments inside the first window", () => { BOOKINGS.forEach((b) => { bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts index 163bb7d79..afbbcbc10 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts @@ -19,6 +19,7 @@ import { acceptOperation, bookBulk, + clearToOperationRequestPending, closeBookingWindow, completeDocReview, createImportSchedule, @@ -84,6 +85,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", { it("seven customers book wheat in the first window; operations accepts them in priority order", () => { ORDER.forEach((suffix) => { bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending(suffix, BOOKING_DAY); acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); @@ -192,7 +194,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", { }); bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY }); - pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending("BSC", REMAINDER_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts index a7a875c59..67236cb9f 100644 --- a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -73,7 +73,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => { cy.mantineSelect(/^Contract Kind/, "General Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.contains("Rail Transport Only", { timeout: 15000 }).click(); - cy.mantineSelect(/^Payment Currency/, /^ETB/); + // Contracts are always quoted in USD now — the billing currency is picked + // per shipment at booking time, not here. cy.contains("button", "Continue").click({ force: true }); // Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft & diff --git a/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts index d7c2c728d..7de010077 100644 --- a/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts @@ -23,6 +23,7 @@ import { acceptExport, apiPost, bookContainers, + clearToOperationRequestPending, createImportSchedule, db, dbBooking, @@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri scheduledDate: BOOKING_DAY, }); isoSeed += b.twenty + b.forty; - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts index 28844bb1c..eaba7c298 100644 --- a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -21,6 +21,8 @@ * Sequential steps of one journey — retries off (steps are not idempotent). */ +import { clearBookingClearance } from "./import-utils"; + const customer = "user@gmail.com"; const companyTin = "0102030405"; // seed-company.sql const opsStaff = "operation@edr.local"; @@ -477,7 +479,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); - cy.mantineSelect(/^Payment Currency/, /^ETB/); + // Contracts are always quoted in USD now — the billing currency is picked + // per shipment at booking time, not here. cy.contains("button", "Continue").click({ force: true }); // Container contracts auto-cover both 20ft & 40ft (no size picker) and the @@ -537,6 +540,12 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.contains("button", "Confirm & book").click(); cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + withBooking("CONTAINER", (b) => { + expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS"); + expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); + clearBookingClearance(b.id, b.scheduled_date!); + }); + withBooking("CONTAINER", (b) => { expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); @@ -561,7 +570,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); - cy.mantineSelect(/^Payment Currency/, /^ETB/); + // Contracts are always quoted in USD now — the billing currency is picked + // per shipment at booking time, not here. cy.contains("button", "Continue").click({ force: true }); cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/); @@ -605,6 +615,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0 cy.contains("button", "Confirm & book").click(); cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + withBooking("BULK", (b) => { + expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS"); + clearBookingClearance(b.id, b.scheduled_date!); + }); + withBooking("BULK", (b) => { expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); }); diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 9f9b53688..ac81d6b6f 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -69,6 +69,44 @@ export function apiPost( ); } +/** + * Drive Fayda identity verification via the API, bypassing the real + * popup+SMS flow entirely — `fayda-mock-e2e` stands in for eSignet's + * token/userinfo endpoints (see docker-compose.e2e.yaml), so a throwaway + * code is enough: start a session, then complete it — mirrors what + * FaydaVerifyPanel's popup + postMessage dance does server-side, minus the + * UI. Uses the browser's OWN auth-token cookie rather than `tokenFor` — + * this only ever runs for a user already logged in in the running test + * (the real flow requires being on an authenticated page to see the + * "Verify with Fayda" button at all), and `tokenFor` only knows how to log + * in fixed seeded accounts, not a signup created earlier in the same test + * with its own freshly-chosen password. + */ +export function completeFaydaVerification(subject: "owner" | "poa") { + return cy.getCookie("auth-token").then((cookie) => { + expect(cookie, "authenticated session (auth-token cookie)").to.not.be.null; + const headers = { Authorization: `Bearer ${cookie!.value}` }; + return cy + .request({ + method: "POST", + url: `${apiUrl()}/api/fayda/verification/start`, + headers, + body: { purpose: "VERIFY", platform: "PORTAL" }, + }) + .then((res) => { + const authorizationUrl: string = res.body.data.authorizationUrl; + const state = new URL(authorizationUrl).searchParams.get("state"); + expect(state, "fayda session state").to.be.a("string"); + return cy.request({ + method: "POST", + url: `${apiUrl()}/api/companies/identity/fayda/complete`, + headers, + body: { subject, code: "e2e-mock-code", state }, + }); + }); + }); +} + export function apiGet(email: string, path: string, failOnStatusCode = true) { return tokenFor(email).then((token) => cy.request({ @@ -457,13 +495,86 @@ export function bookBulk(opts: { }); } +/** + * Upload one ad-hoc doc → GL approves it → finalize → customer proceeds with + * the shipment day. The e2e seed configures no required documents, so one + * doc satisfies the 100%-approved gate. Takes a bookingId directly so specs + * with their own booking lookup (not the *-suffix convention) can reuse it. + * + * KNOWN GAP — customs (`customsClearingEnabled`) bookings do NOT use this + * path: `finalizeClearance` rejects them outright ("General customs bookings + * use phased clearance — complete milestones via the phased actions instead + * of finalize"). Their real flow is a separate multi-step phased chain + * (transit permit upload → finalizePreClearance → delivery order upload for + * IMPORT; a shorter EXPORT_RELEASED milestone for EXPORT) that no spec here + * drives yet — see booking-clearance.service.ts / clearance-workflow.service.ts. + * Calling this on a customs booking 400s at the finalize step; callers with + * customs suffixes in the mix (the full_train specs' BF-series customs: + * true bookings) currently accept that those specific bookings — and only + * those — fail here until the phased-clearance walk is written. + */ +export function clearBookingClearance(bookingId: string, scheduledDate: string) { + glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e"); + apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(customer, `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate }) + .its("status") + .should("be.oneOf", [200, 201]); +} + +/** + * Every contract booking is now born in the clearance gate — walk a *-suffix + * booking from AWAITING_DOCUMENTS to OPERATION_REQUEST_PENDING via + * {@link clearBookingClearance}. + */ +export function clearToOperationRequestPending(suffix: string, scheduledDate: string) { + withBooking(suffix, (b) => { + expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); + clearBookingClearance(b.id, scheduledDate); + }); + pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 5); +} + +/** + * DOMESTIC (intercity) bookings never pick a shipment day — there is no + * `clearance/proceed` step for them. `finalizeClearance` sends an approved + * intercity booking straight to FULLY_EXECUTED (the ride-along pool); staff + * assign it to a passing train separately. Takes a bookingId directly, like + * {@link clearBookingClearance}. + */ +export function clearIntercityBookingClearance(bookingId: string) { + glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e"); + apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`) + .its("status") + .should("be.oneOf", [200, 201]); +} + +/** Walk a *-suffix DOMESTIC booking from AWAITING_DOCUMENTS to FULLY_EXECUTED. */ +export function clearIntercityToFullyExecuted(suffix: string) { + withBooking(suffix, (b) => { + expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); + clearIntercityBookingClearance(b.id); + }); + pollBookingStatus(suffix, "FULLY_EXECUTED", 10); +} + /** * Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A): - * AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize → - * CLEARANCE_READY → customer proceeds with the shipment day → - * OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e - * seed configures no required documents, so one ad-hoc doc satisfies the - * 100%-approved gate. + * AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending) + * → ops accept → FULLY_EXECUTED (pool). */ export function clearGeneralBooking( suffix: string, @@ -471,22 +582,7 @@ export function clearGeneralBooking( /** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */ mode: "import" | "export" = "import", ) { - withBooking(suffix, (b) => { - expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); - glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e"); - apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, { - fileKey: "custom_e2e", - status: "APPROVED", - }) - .its("status") - .should("be.oneOf", [200, 201]); - apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`) - .its("status") - .should("be.oneOf", [200, 201]); - apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate }) - .its("status") - .should("be.oneOf", [200, 201]); - }); + clearToOperationRequestPending(suffix, scheduledDate); if (mode === "export") acceptExport(suffix); else acceptOperation(suffix); } diff --git a/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts index bfae79c6a..16849e4af 100644 --- a/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts @@ -21,6 +21,7 @@ import { acceptOperation, apiPost, bookContainers, + clearToOperationRequestPending, closeBookingWindow, completeBookingMilestone, completeDocReview, @@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 } scheduledDate: BOOKING_DAY, }); isoSeed += b.twenty + b.forty; - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts index a312063ba..190af595b 100644 --- a/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts @@ -22,6 +22,7 @@ import { resetCorridorDay, acceptOperation, bookContainers, + clearToOperationRequestPending, closeBookingWindow, completeDocReview, createImportSchedule, @@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri scheduledDate: BOOKING_DAY, }); isoSeed += s.twenty + s.forty; + clearToOperationRequestPending(suffix, BOOKING_DAY); acceptOperation(suffix); }); ORDER.forEach((suffix, i) => setPriority(suffix, i + 1)); @@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri twenty: 32, scheduledDate: REMAINDER_DAY, }); - pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending("SC", REMAINDER_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts index b74de4993..8f478227a 100644 --- a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -18,9 +18,11 @@ * 7. backoffice — operations approves the document, then finalizes document * approval → FULLY_EXECUTED * 8. portal — customer books 2 × 20ft under the contract (intercity has - * no shipment date) → booking OPERATION_REQUEST_PENDING - * 9. backoffice — operations accepts the operation request → booking - * FULLY_EXECUTED (intercity waiting pool) + * no shipment date) → booking AWAITING_DOCUMENTS → customer + * uploads a doc, GL approves, finalize → DOMESTIC bookings + * skip the shipment-day step entirely, so finalize alone + * lands FULLY_EXECUTED (the intercity waiting pool) — there + * is no separate operations "accept the request" step. * 10. backoffice — operations creates the EXPORT route * Mojo → Dire Dawa → Djibouti Port (distances seeded) * 11. backoffice — operations schedules the export train (built Train-Builder @@ -34,6 +36,8 @@ * Sequential steps of one journey — retries off (steps are not idempotent). */ +import { clearIntercityBookingClearance } from "./import-utils"; + const customer = "user@gmail.com"; const companyTin = "0102030405"; // seed-company.sql const opsStaff = "operation@edr.local"; @@ -153,12 +157,13 @@ function createIntercityContract() { cy.loginPortal(customer); cy.visitPortal("/contracts/new"); - // Step 0 — Setup. Intercity forces ETB and hides the customs section. + // Step 0 — Setup. Intercity hides the customs section; the billing currency + // step moved to booking time (intercity always bills ETB there, per app + // comment in step2-service-type.tsx), so it is not selected here. cy.mantineSelect(/^Operation Type/, /^Intercity$/); cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); cy.mantineSelect(/^New or Renewal/, "New Contract"); cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click(); - cy.mantineSelect(/^Payment Currency/, /^ETB/); cy.contains("button", "Continue").click({ force: true }); // Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container @@ -422,21 +427,15 @@ describe("intercity one-time journey: contract → booking → export train", { cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); withBooking((b) => { - expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); - expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); + expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS"); + clearIntercityBookingClearance(b.id); }); - }); - - it("operations accepts the operation request — booking joins the intercity pool", () => { - cy.loginBackoffice(opsStaff); - withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); - - cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); - cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); - cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); withBooking((b) => { - expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED"); + expect(b.status, "DOMESTIC finalize skips straight to the pool").to.eq( + "FULLY_EXECUTED", + ); + expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); expect(b.train_schedule_id).to.eq(null); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts index df458fc65..fd05814a7 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts @@ -21,6 +21,7 @@ import { apiPost, bookBulk, bookContainers, + clearToOperationRequestPending, completeBookingMilestone, createImportSchedule, db, @@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r } else { bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY }); } - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts index e1b779109..fe7d3727d 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts @@ -21,6 +21,7 @@ import { apiPost, bookBulk, bookContainers, + clearToOperationRequestPending, closeBookingWindow, completeBookingMilestone, completeDocReview, @@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie } else { bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY }); } - pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts index 46f27d9a1..88ba5e81f 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts @@ -23,6 +23,7 @@ import { acceptOperation, bookBulk, bookContainers, + clearToOperationRequestPending, closeBookingWindow, completeDocReview, createImportSchedule, @@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk scheduledDate: BOOKING_DAY, }); isoSeed += b.twenty; + clearToOperationRequestPending(b.suffix, BOOKING_DAY); acceptOperation(b.suffix); }); BULKS.forEach((b) => { bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY }); + clearToOperationRequestPending(b.suffix, BOOKING_DAY); acceptOperation(b.suffix); }); CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1)); @@ -209,7 +212,7 @@ describe("mixed import: container split; container-freed wagons promote the bulk twenty: 20, scheduledDate: REMAINDER_DAY, }); - pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5); + clearToOperationRequestPending("MSX", REMAINDER_DAY); }); }); diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts index ed6df900b..bb077df6b 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts @@ -18,15 +18,15 @@ * journey's user/company from the DB instead of module variables. */ +import { completeFaydaVerification } from "./import-utils"; + const stamp = Date.now(); const email = `e2e.onboard.${stamp}@example.com`; // Ethiopian mobile: 9 + 8 digits, unique per run. const phoneNational = `9${String(stamp).slice(-8)}`; const signupPassword = "Password@e2e1"; -const companyName = `E2E Onboard Co ${stamp}`; const tin = String(stamp).slice(-10).padStart(10, "1"); const vat = String(stamp + 1).slice(-10).padStart(10, "2"); -const fan = String(stamp).slice(-13).padStart(16, "3"); const portal = () => Cypress.env("portalUrl") as string; @@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) { describe("customer onboarding journey", { retries: 0 }, () => { it("signs up with OTP and completes the onboarding wizard", () => { - // The eTrade TIN lookup 400s in e2e (external service unreachable). The - // form handles it ("fill in the details manually") but axios also throws - // an uncaught rejection — ignore just that one. - cy.on("uncaught:exception", (err) => - err.message.includes("Request failed with status code 400") ? false : true, - ); cy.visit(`${portal()}/signup`); fill(/^First name/, "Onboard"); @@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => { cy.contains("button", "Importer").click(); cy.get(".mantine-Modal-content").contains("button", "Continue").click(); - // Company step. TIN first — the eTrade auto-lookup fails in e2e (no - // external network) and the form allows manual entry. - cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin); - fill(/^Company Name/, companyName); - fill(/^Company Email/, `ops.${stamp}@example.com`); - fillPhone(0, "911234567"); - fill(/^Location/, "Addis Ababa, Ethiopia"); + // Ethiopian companies gate the "Owner identity" step on Fayda + // verification — a real popup + SMS OTP flow that can't run in e2e. + // Complete it via the API against fayda-mock-e2e (the profile this + // attaches to was just created by the nationality/role step above). + // The wizard already fetched `identity` once when this step mounted — + // completing verification out-of-band (no popup, so no onVerified + // callback fires) leaves that fetch stale, so reload to force a fresh + // one. Wizard progress resumes server-side, so this doesn't lose the + // nationality/role step just completed. + completeFaydaVerification("owner"); + cy.reload(); + cy.contains("Confirm your VAT number", { timeout: 20000 }).should( + "be.visible", + ); + + // Company step. TIN auto-triggers the eTrade lookup once it's a full 10 + // digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e). + // A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House + // No as read-only (ETradeCompanyCard) — nothing left to type there, and + // Company Email/Phone/Location were dropped from this step entirely (the + // Fayda-verified owner supplies contact details now). By label, not + // placeholder: the VAT Number field on this same step shares the TIN + // field's "0012345678" placeholder, so a placeholder selector matches 2. + fill(/^TIN Number/, tin); + cy.contains("Verified with eTrade", { timeout: 15000 }).should( + "be.visible", + ); + // handleETradeDataLoaded sets several fields in sequence (name, region, + // zone, woreda, kebele, houseNo) — each a render, still settling right + // after the badge appears. Typing into VAT immediately raced one of + // those and detached mid-type; let it finish before touching the form. + cy.wait(500); fill(/^VAT Number/, vat); - cy.get('input[placeholder="1234567890123456"]').type(fan); - cy.mantineSelect(/^Region/, "Addis Ababa"); - fill(/^Zone/, "Zone 1"); - fill(/^Woreda/, "Woreda 1"); - fill(/^Kebele/, "Kebele 1"); - fill(/^House No/, "123"); cy.get(".mantine-Modal-content").contains("button", "Continue").click(); // Personnel (general manager). diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 43566075b..419e6b3fa 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -141,34 +141,55 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | * takes explicit Start/End dates and pre-fills neither, so "Accept & start * approval" stays disabled until both are set. * - * Mantine's DateInput parses typed text with its valueFormat, which defaults to - * "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces. + * The fields are Mantine DateTimePickers — a button that opens a calendar + * popover, not a typeable input. Zoom out to the decade view (2 clicks on the + * header's middle control, which has no [data-direction]) then drill back + * down year → month → day, and confirm with the popover's submit (check) + * button — clicking a day alone only stages the value, it does not close + * the popover or commit the pick. */ +const MONTH_ABBR = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +] as const; +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] as const; + Cypress.Commands.add("acceptValidityWindow", (days = 365) => { const start = new Date(); const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000); - const asInput = (d: Date) => - d.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }); - for (const [label, value] of [ - ["Start date", start], - ["End date", end], - ] as const) { + const pickDate = (label: string, date: Date) => { cy.contains("label", label) .invoke("attr", "for") - .then((id) => { - cy.get(`[id="${id}"]`) - .clear({ force: true }) - .type(asInput(value), { force: true }) - // DateInput commits on blur; it also closes the calendar popover, - // which would otherwise sit over the submit button. - .blur(); - }); - } + .then((id) => cy.get(`[id="${id}"]`).click({ force: true })); + + // month view -> year view -> decade view. + for (let i = 0; i < 2; i++) { + cy.get('.mantine-Popover-dropdown [data-direction="previous"]') + .siblings("button") + .first() + .click({ force: true }); + } + + cy.get(".mantine-Popover-dropdown") + .contains("button", new RegExp(`^${date.getFullYear()}$`)) + .click({ force: true }); + cy.get(".mantine-Popover-dropdown") + .contains("button", new RegExp(`^${MONTH_ABBR[date.getMonth()]}$`)) + .click({ force: true }); + + const dayAriaLabel = `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`; + cy.get(`.mantine-Popover-dropdown [aria-label="${dayAriaLabel}"]`).click({ + force: true, + }); + cy.get(".mantine-DateTimePicker-submitButton").click({ force: true }); + }; + + pickDate("Start date", start); + pickDate("End date", end); }); /** diff --git a/e2e/freight/etrade-mock/server.js b/e2e/freight/etrade-mock/server.js new file mode 100644 index 000000000..95f43ded2 --- /dev/null +++ b/e2e/freight/etrade-mock/server.js @@ -0,0 +1,124 @@ +// Stand-in for https://etrade.gov.et in the e2e stack. ETradeService's base +// URL is hardcoded (apps/edr-freight-api/src/modules/companies/services/etrade.service.ts), +// not env-configurable, so this is reached by aliasing this container AS +// "etrade.gov.et" on the compose network (see docker-compose.e2e.yaml) rather +// than by pointing an env var here. HTTPS because the real service is HTTPS +// — the cert itself only needs to exist, never be trusted, since +// ETradeService's httpsAgent sets rejectUnauthorized:false. The compose +// service's command generates a throwaway self-signed cert into +// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a +// key/cert is committed here. +// +// Every TIN resolves to the same canned company — these specs don't care +// about per-TIN business logic, only that the lookup succeeds so the rest +// of the company-info form (name, address, manager) auto-fills instead of +// staying gated behind a "No matching business record" alert. +const https = require("node:https"); +const fs = require("node:fs"); + +const PORT = process.env.PORT || 443; + +const options = { + key: fs.readFileSync(process.env.TLS_KEY_PATH || "/tmp/etrade-mock/key.pem"), + cert: fs.readFileSync( + process.env.TLS_CERT_PATH || "/tmp/etrade-mock/cert.pem", + ), +}; + +function companyInfo(tin) { + return { + Tin: tin, + LegalCondtion: "Private Limited Company", + RegNo: "REG-E2E-0001", + RegDate: "2020-01-01", + // Embeds the (per-run-unique) TIN rather than a static name — specs + // that look a company up by name (e.g. onboarding.cy.ts) need this to + // stay unique across repeated e2e runs against the same warm DB, same + // as it would be if the customer had typed a real company name. + BusinessName: `E2E Mock Trading PLC ${tin}`, + BusinessNameAmh: "ኢቱኢ ሞክ ትሬዲንግ", + PaidUpCapital: 100000, + AssociateShortInfos: [], + Businesses: [ + { + MainGuid: "e2e-guid-0001", + OwnerTIN: tin, + DateRegistered: "2020-01-01", + TradeNameAmh: "ኢቱኢ", + TradesName: "E2E Mock Trading", + LicenceNumber: "LIC-E2E-0001", + RenewalDate: "2026-01-01", + RenewedFrom: "2025-01-01", + RenewedTo: "2027-01-01", + BusinessLicensingGroupMain: null, + SubGroups: null, + }, + ], + }; +} + +function businessInfo(tin) { + return { + MainGuid: "e2e-guid-0001", + OwnerTIN: tin, + DateRegistered: "2020-01-01", + TradeName: "E2E Mock Trading", + LicenceNumber: "LIC-E2E-0001", + Status: 1, + StatusDescription: "Active", + Capital: 100000, + AssociateShortInfos: [ + { + Position: "Manager", + ManagerName: "አበበ በቀለ", + ManagerNameEng: "Abebe Bekele", + Photo: null, + MobilePhone: "+251911223344", + RegularPhone: null, + }, + ], + AddressInfo: { + Region: "ADDIS ABABA", + Zone: "Zone 1", + Woreda: "Woreda 1", + Kebele: "Kebele 1", + HouseNo: "123", + MobilePhone: "+251911223344", + RegularPhone: "", + }, + RenewedTo: "2027-01-01", + RenewedToDateString: "2027-01-01", + RenewalDate: "2026-01-01", + RenewedFrom: "2025-01-01", + CancellationDate: null, + }; +} + +const server = https.createServer(options, (req, res) => { + const url = new URL(req.url, `https://${req.headers.host}`); + + const regMatch = url.pathname.match( + /^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/, + ); + if (req.method === "GET" && regMatch) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(companyInfo(regMatch[1]))); + return; + } + + if ( + req.method === "GET" && + url.pathname === "/api/BusinessMain/GetBusinessByLicenseNo" + ) { + const tin = url.searchParams.get("Tin") || ""; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(businessInfo(tin))); + return; + } + + res.writeHead(404).end(); +}); + +server.listen(PORT, () => { + console.log(`etrade-mock listening on ${PORT}`); +}); diff --git a/e2e/freight/fayda-mock/server.js b/e2e/freight/fayda-mock/server.js new file mode 100644 index 000000000..e9a133c34 --- /dev/null +++ b/e2e/freight/fayda-mock/server.js @@ -0,0 +1,52 @@ +// Minimal eSignet stand-in for the e2e stack. freight-api-e2e's +// FAYDA_TOKEN_ENDPOINT / FAYDA_USERINFO_ENDPOINT point here instead of the +// real (unreachable) Fayda infrastructure. It does not validate the +// client_assertion, code, or code_verifier it's sent — Cypress drives the +// verification via the API directly (POST start, then POST complete with a +// throwaway code), never through the real popup+SMS flow, so nothing here +// needs to be cryptographically real, only shaped like a working OIDC token +// + userinfo response. +const http = require("node:http"); + +const PORT = process.env.PORT || 4400; + +const USERINFO = { + sub: "e2e-fayda-sub-0001", + name: "Abebe Bekele", + "name#en": "Abebe Bekele", + email: "abebe.bekele@example.com", + phone_number: "+251911223344", + gender: "Male", + "gender#en": "Male", + birthdate: "1990-01-01", + "address#en": "Bole Sub City, Addis Ababa, Ethiopia", +}; + +const server = http.createServer((req, res) => { + // Drain the body so the client's request completes cleanly even though we + // never read it (POST /token sends a form-urlencoded body we don't parse). + req.on("data", () => {}); + req.on("end", () => { + if (req.method === "POST" && req.url.startsWith("/token")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + access_token: "e2e-mock-access-token", + token_type: "Bearer", + expires_in: 3600, + }), + ); + return; + } + if (req.method === "GET" && req.url.startsWith("/userinfo")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(USERINFO)); + return; + } + res.writeHead(404).end(); + }); +}); + +server.listen(PORT, () => { + console.log(`fayda-mock listening on ${PORT}`); +}); diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs index 81c8bf216..ee59f0098 100644 --- a/e2e/freight/scripts/e2e.mjs +++ b/e2e/freight/scripts/e2e.mjs @@ -16,6 +16,7 @@ */ import { execFileSync, spawnSync } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, join, resolve } from "node:path"; @@ -110,6 +111,24 @@ async function resolvePorts() { return ports; } +/** + * Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 — signs a client_assertion + * JWT that fayda-mock-e2e never verifies (see docker-compose.e2e.yaml), so + * a fresh one every launch is fine; nothing needs it to stay stable across + * runs the way ports do. Generated here instead of committed as a literal + * blob in the compose file so nothing that looks like a private key sits in + * git history, even though this one only unlocks a local mock. + */ +function fakeFaydaPrivateKeyBase64() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + jwk.kty = "RSA"; + jwk.use = "sig"; + jwk.alg = "RS256"; + jwk.kid = "e2e-fayda-mock"; + return Buffer.from(JSON.stringify(jwk)).toString("base64"); +} + function envFor(ports) { return { ...process.env, @@ -120,6 +139,8 @@ function envFor(ports) { CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`, CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`, E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`, + FAYDA_PRIVATE_KEY_BASE64: + process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(), }; }