From ccbc3418c53fec1c552e85d7bc43b1d2303bd303 Mon Sep 17 00:00:00 2001 From: pwei1018 Date: Thu, 22 Oct 2026 08:58:32 -0700 Subject: [PATCH 1/2] feat. implement cloud run deployment for Express-Checkout --- web/express-checkout/.dockerignore | 11 + web/express-checkout/Dockerfile | 30 +++ .../devops/cloudbuild-pr.yaml | 59 ----- .../devops/gcp/clouddeploy.yaml | 78 +++++++ .../devops/{vaults.env => vaults.gcp.env} | 8 +- web/express-checkout/nuxt.config.ts | 80 +++++-- web/express-checkout/package.json | 4 +- web/express-checkout/pnpm-workspace.yaml | 9 + web/express-checkout/server/middleware/csp.ts | 14 ++ .../server/plugins/error-logger.ts | 17 ++ .../server/plugins/request-logger.ts | 29 +++ .../server/plugins/storage-polyfill.ts | 59 +++++ .../server/routes/pay-proxy/[...path].ts | 77 +++++++ web/express-checkout/server/utils/logger.ts | 34 +++ .../tests/unit/server/middleware/csp.spec.ts | 66 ++++++ .../unit/server/plugins/error-logger.spec.ts | 75 +++++++ .../server/plugins/request-logger.spec.ts | 125 +++++++++++ .../server/plugins/storage-polyfill.spec.ts | 103 +++++++++ .../unit/server/routes/pay-proxy.spec.ts | 203 ++++++++++++++++++ .../tests/unit/server/utils/logger.spec.ts | 108 ++++++++++ web/express-checkout/vitest.config.ts | 13 +- 21 files changed, 1109 insertions(+), 93 deletions(-) create mode 100644 web/express-checkout/.dockerignore create mode 100644 web/express-checkout/Dockerfile delete mode 100644 web/express-checkout/devops/cloudbuild-pr.yaml create mode 100644 web/express-checkout/devops/gcp/clouddeploy.yaml rename web/express-checkout/devops/{vaults.env => vaults.gcp.env} (82%) create mode 100644 web/express-checkout/server/middleware/csp.ts create mode 100644 web/express-checkout/server/plugins/error-logger.ts create mode 100644 web/express-checkout/server/plugins/request-logger.ts create mode 100644 web/express-checkout/server/plugins/storage-polyfill.ts create mode 100644 web/express-checkout/server/routes/pay-proxy/[...path].ts create mode 100644 web/express-checkout/server/utils/logger.ts create mode 100644 web/express-checkout/tests/unit/server/middleware/csp.spec.ts create mode 100644 web/express-checkout/tests/unit/server/plugins/error-logger.spec.ts create mode 100644 web/express-checkout/tests/unit/server/plugins/request-logger.spec.ts create mode 100644 web/express-checkout/tests/unit/server/plugins/storage-polyfill.spec.ts create mode 100644 web/express-checkout/tests/unit/server/routes/pay-proxy.spec.ts create mode 100644 web/express-checkout/tests/unit/server/utils/logger.spec.ts diff --git a/web/express-checkout/.dockerignore b/web/express-checkout/.dockerignore new file mode 100644 index 00000000..a3933bb6 --- /dev/null +++ b/web/express-checkout/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.nuxt +.output +.data +.git +dist +coverage +tests +Dockerfile +.dockerignore +.env* diff --git a/web/express-checkout/Dockerfile b/web/express-checkout/Dockerfile new file mode 100644 index 00000000..e834ab4d --- /dev/null +++ b/web/express-checkout/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +# ---- Dependencies ---- +FROM node:24-slim AS deps +ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# postinstall runs `nuxt prepare`, which needs the sources, so skip scripts here +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts + +# ---- Build ---- +FROM deps AS build +COPY . . +RUN pnpm exec nuxt prepare +# `nuxt build` produces the self-contained Nitro server in .output +RUN pnpm exec nuxt build + +# ---- Runtime ---- +FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=8080 +COPY --from=build --chown=nonroot:nonroot /app/.output ./ + +EXPOSE 8080 +# The distroless image entrypoint is already `node` +CMD ["--enable-source-maps", "server/index.mjs"] diff --git a/web/express-checkout/devops/cloudbuild-pr.yaml b/web/express-checkout/devops/cloudbuild-pr.yaml deleted file mode 100644 index 76294d07..00000000 --- a/web/express-checkout/devops/cloudbuild-pr.yaml +++ /dev/null @@ -1,59 +0,0 @@ -steps: - - name: gcr.io/cloud-builders/gcloud - args: - - '-c' - - cp .env.example .env - dir: web/express-checkout - entrypoint: bash - - name: 'node:24' - env: - - NUXT_PUBLIC_LD_CLIENT_ID=$_NUXT_PUBLIC_LD_CLIENT_ID - - NUXT_PUBLIC_X_API_KEY=$_NUXT_PUBLIC_X_API_KEY - script: | - #!/usr/bin/env bash - npm install --global pnpm - BUILD_FOLDER=web/express-checkout - pnpm --filter "./$BUILD_FOLDER" install - pnpm --filter "./$BUILD_FOLDER" build - mv $BUILD_FOLDER/dist ./dist - mv $BUILD_FOLDER/firebase.json . - ls - - name: us-docker.pkg.dev/firebase-cli/us/firebase - args: - - '-c' - - >- - firebase hosting:channel:deploy --project=yfthig-dev PR-$_PR_NUMBER > - /workspace/firebase-deploy.txt - entrypoint: bash - - name: gcr.io/cloud-builders/curl - args: - - '-c' - - > - # Read from "/workspace" - - url=`cat /workspace/firebase-deploy.txt| grep 'Channel URL' | grep -Eo - "https://[a-zA-Z0-9./?=_%:-]*"` - - echo "this is the body:" - - echo '{"body": "Temporary Url for review:'"$url"'"}' - - curl -X POST \ - - https://api.github.com/repos/bcgov/pay-ui/issues/$_PR_NUMBER/comments \ - - --header "Authorization: Token $$TOKEN" \ - - --header "Accept: application/vnd.github.v3+json" \ - - --data-binary '{"body": "Temporary Url for review: '"$url"'"}' - id: Update the PR - entrypoint: bash - secretEnv: - - TOKEN -options: - dynamicSubstitutions: true -availableSecrets: - secretManager: - - versionName: projects/$PROJECT_ID/secrets/token-pr-review/versions/latest - env: TOKEN diff --git a/web/express-checkout/devops/gcp/clouddeploy.yaml b/web/express-checkout/devops/gcp/clouddeploy.yaml new file mode 100644 index 00000000..ca0cde90 --- /dev/null +++ b/web/express-checkout/devops/gcp/clouddeploy.yaml @@ -0,0 +1,78 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +apiVersion: deploy.cloud.google.com/v1 +kind: DeliveryPipeline +metadata: + name: express-checkout-pipeline +description: Deployment pipeline +serialPipeline: + stages: + - targetId: gtksf3-dev + profiles: [dev] + strategy: + standard: + verify: false + deployParameters: + - values: + deploy-env: "development" + deploy-project-id: "gtksf3-dev" + service-name: "express-checkout-dev" + container-name: "express-checkout-dev" + cloudsql-instances: "" + service-account: "sa-api@gtksf3-dev.iam.gserviceaccount.com" + - targetId: gtksf3-test + profiles: [test] + strategy: + standard: + verify: false + deployParameters: + - values: + deploy-env: "test" + deploy-project-id: "gtksf3-test" + service-name: "express-checkout-test" + container-name: "express-checkout-test" + app-env: "test" + cloudsql-instances: "" + service-account: "sa-api@gtksf3-test.iam.gserviceaccount.com" + - targetId: gtksf3-sandbox + profiles: [sandbox] + strategy: + standard: + verify: false + deployParameters: + - values: + deploy-env: "sandbox" + deploy-project-id: "gtksf3-tools" + service-name: "express-checkout-sandbox" + container-name: "express-checkout-sandbox" + app-env: "sandbox" + cloudsql-instances: "" + service-account: "sa-api@gtksf3-tools.iam.gserviceaccount.com" + - targetId: gtksf3-prod + profiles: [prod] + strategy: + standard: + verify: false + deployParameters: + - values: + deploy-env: "production" + deploy-project-id: "gtksf3-prod" + service-name: "express-checkout-prod" + container-name: "express-checkout-prod" + app-env: "production" + cloudsql-instances: "" + service-account: "sa-api@gtksf3-prod.iam.gserviceaccount.com" + max-scale: "10" + container-concurrency: "20" diff --git a/web/express-checkout/devops/vaults.env b/web/express-checkout/devops/vaults.gcp.env similarity index 82% rename from web/express-checkout/devops/vaults.env rename to web/express-checkout/devops/vaults.gcp.env index e4a85a88..f54a750b 100644 --- a/web/express-checkout/devops/vaults.env +++ b/web/express-checkout/devops/vaults.gcp.env @@ -3,7 +3,7 @@ NUXT_PUBLIC_BASE_URL="op://web-url/$APP_ENV/express-checkout/EXPRESS_CHECKOUT_WE NUXT_PUBLIC_AUTH_WEB_URL="op://web-url/$APP_ENV/auth-web/AUTH_WEB_URL" # APIGEE key -NUXT_PUBLIC_X_API_KEY="op://API/$APP_ENV/business-api/BUSINESS_API_KEY" # TODO: confirm same key or dedicated express-checkout entry +NUXT_X_API_KEY="op://API/$APP_ENV/business-api/BUSINESS_API_KEY" # TODO: confirm same key or dedicated express-checkout entry # App name (header sent with every API call, distinguishes traffic in logs) NUXT_PUBLIC_APP_NAME="express-checkout" @@ -17,7 +17,7 @@ NUXT_PUBLIC_AUTH_API_URL="op://API/$APP_ENV/auth-api/AUTH_API_URL" NUXT_PUBLIC_AUTH_API_VERSION="op://API/$APP_ENV/auth-api/AUTH_API_VERSION" # Pay API -NUXT_PUBLIC_PAY_API_URL="op://API/$APP_ENV/pay-api/PAY_API_URL" +NUXT_PAY_API_URL="op://API/$APP_ENV/pay-api/PAY_API_URL" NUXT_PUBLIC_PAY_API_VERSION="op://API/$APP_ENV/pay-api/PAY_API_VERSION" # LaunchDarkly @@ -29,3 +29,7 @@ NUXT_PUBLIC_IDP_URL="op://keycloak/$APP_ENV/base/KEYCLOAK_AUTH_BASE_URL" NUXT_PUBLIC_IDP_REALM="op://keycloak/$APP_ENV/base/KEYCLOAK_REALMNAME" NUXT_PUBLIC_IDP_CLIENTID="op://keycloak/$APP_ENV/express-checkout/UI_KEYCLOAK_RESOURCE_NAME" # TODO: DevOps to provision Keycloak client NUXT_PUBLIC_SITEMINDER_LOGOUT_URL="op://web-url/$APP_ENV/siteminder/SITEMINDER_LOGOUT_URL" + + +NUXT_CSP_EXTRA="op://CD/$APP_ENV/base/CSP_EXTRA" +LOG_LEVEL="op://CD/$APP_ENV/express-checkout/LOG_LEVEL" diff --git a/web/express-checkout/nuxt.config.ts b/web/express-checkout/nuxt.config.ts index e97b982a..62798db3 100644 --- a/web/express-checkout/nuxt.config.ts +++ b/web/express-checkout/nuxt.config.ts @@ -1,21 +1,59 @@ -// https://nuxt.com/docs/api/configuration/nuxt-config +// Lowercase keys so these merge with Nuxt's built-in route rules instead of duplicating headers. +// Content-Security-Policy is set at runtime from NUXT_CSP_EXTRA by server/middleware/csp.ts. +const securityHeaders = { + 'access-control-allow-origin': '*', + 'x-frame-options': 'DENY', + 'x-content-type-options': 'nosniff', + 'x-xss-protection': '1; mode=block', + 'cache-control': 'private, no-cache, no-store, must-revalidate', + 'pragma': 'no-cache', + 'referrer-policy': 'no-referrer', + 'feature-policy': "microphone 'self'", + 'strict-transport-security': 'max-age=31536000;' +} + +// Nitro route rules match paths, not extensions, so the long-lived cache is applied per static asset folder +const longCache = { headers: { 'cache-control': 'public,max-age=31536000' } } +// https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ - ssr: false, + app: { + head: { + title: 'Registry Digital Services Express Checkout' + } + }, + + ssr: true, + + // Auth is browser-only (keycloak-js, token in memory, no cookie), so the server cannot render + // authenticated pages; they are served as an SPA shell. Server routes (/pay-proxy) are unaffected. + // Opt a route into SSR here only if it renders without the user's token. + // Only the landing pages qualify today ('/en-CA' and '/fr-CA' are static and safe to set to + // `ssr: true`); the /pay/** pages need the browser session, so they must stay SPA. + // Headers mirror the previous Firebase Hosting config (firebase.json). + routeRules: { + '/': { redirect: '/en-CA' }, + '/**': { ssr: false, headers: securityHeaders }, + '/_nuxt/**': longCache, + '/css/**': longCache, + '/js/**': longCache, + '/fonts/**': longCache, + '/img/**': longCache, + '/BCGovLogo/**': longCache, + '/favicon.ico': longCache, + // Build manifest (json) must not be cached, so clients pick up new deployments + '/_nuxt/builds/**': { + headers: { 'cache-control': 'no-cache, no-store, must-revalidate' } + } + }, devtools: { enabled: false }, - modules: [ - '@nuxt/test-utils/module' - ], + modules: ['@nuxt/test-utils/module'], - extends: [ - '@sbc-connect/nuxt-pay' - ], + extends: ['@sbc-connect/nuxt-pay'], - css: [ - '~/assets/css/main.css' - ], + css: ['~/assets/css/main.css'], router: { options: { @@ -23,19 +61,8 @@ export default defineNuxtConfig({ } }, - routeRules: { - '/': { redirect: '/en-CA' } - }, - imports: { - dirs: [ - 'stores', - 'composables', - 'enums', - 'interfaces', - 'types', - 'utils' - ] + dirs: ['stores', 'composables', 'enums', 'interfaces', 'types', 'utils'] }, i18n: { @@ -61,7 +88,14 @@ export default defineNuxtConfig({ compatibilityDate: '2025-12-09', runtimeConfig: { + // Server-only (NUXT_PAY_API_URL, NUXT_X_API_KEY); never serialized to the browser + payApiUrl: '', + xApiKey: '', + // Full Content-Security-Policy header value (NUXT_CSP_EXTRA) + cspExtra: '', public: { + // Browser calls pay-api through the server proxy so the API key stays on the server + payApiUrl: '/pay-proxy', version: `Pay UI v${process.env.npm_package_version || ''}`, playwright: process.env.playwright === 'true' } diff --git a/web/express-checkout/package.json b/web/express-checkout/package.json index a14c1d1d..5192dd21 100644 --- a/web/express-checkout/package.json +++ b/web/express-checkout/package.json @@ -6,11 +6,11 @@ "engines": { "node": ">=24" }, - "packageManager": "pnpm@10.13.1", + "packageManager": "pnpm@11.9.0", "license": "BSD-3-Clause", "scripts": { "build-check": "nuxt build", - "build": "nuxt generate", + "build": "nuxt build", "dev": "nuxt dev --port 3001", "generate": "nuxt generate", "preview": "nuxt preview", diff --git a/web/express-checkout/pnpm-workspace.yaml b/web/express-checkout/pnpm-workspace.yaml index 3334c0e4..988eb764 100644 --- a/web/express-checkout/pnpm-workspace.yaml +++ b/web/express-checkout/pnpm-workspace.yaml @@ -1 +1,10 @@ packages: [] +allowBuilds: + '@parcel/watcher': true + '@sbc-connect/nuxt-auth': true + '@sbc-connect/nuxt-base': true + '@sbc-connect/nuxt-forms': true + '@sbc-connect/nuxt-pay': true + '@tailwindcss/oxide': true + esbuild: true + vue-demi: true diff --git a/web/express-checkout/server/middleware/csp.ts b/web/express-checkout/server/middleware/csp.ts new file mode 100644 index 00000000..2a38eee0 --- /dev/null +++ b/web/express-checkout/server/middleware/csp.ts @@ -0,0 +1,14 @@ +import { log } from '../utils/logger' + +let warned = false + +// The full Content-Security-Policy comes from NUXT_CSP_EXTRA (loaded from vault) so it can change without a rebuild +export default defineEventHandler((event) => { + const policy = useRuntimeConfig(event).cspExtra?.trim() + if (policy) { + setResponseHeader(event, 'content-security-policy', policy) + } else if (!warned) { + warned = true + log('WARNING', 'NUXT_CSP_EXTRA is not set; responses are served without a Content-Security-Policy') + } +}) diff --git a/web/express-checkout/server/plugins/error-logger.ts b/web/express-checkout/server/plugins/error-logger.ts new file mode 100644 index 00000000..78199023 --- /dev/null +++ b/web/express-checkout/server/plugins/error-logger.ts @@ -0,0 +1,17 @@ +import { log } from '../utils/logger' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('error', (error, { event }) => { + const req = event?.node?.req + // "stack_trace" + serviceContext lets Cloud Error Reporting group these errors + log('ERROR', error.message, { + stack_trace: error.stack, + serviceContext: { service: process.env.K_SERVICE ?? 'express-checkout', version: process.env.K_REVISION }, + httpRequest: req && { + requestMethod: req.method, + requestUrl: req.url, + userAgent: req.headers['user-agent'] + } + }, event) + }) +}) diff --git a/web/express-checkout/server/plugins/request-logger.ts b/web/express-checkout/server/plugins/request-logger.ts new file mode 100644 index 00000000..3fed9955 --- /dev/null +++ b/web/express-checkout/server/plugins/request-logger.ts @@ -0,0 +1,29 @@ +import { log } from '../utils/logger' + +const SKIPPED = /^\/(_nuxt\/|__nuxt|favicon\.ico|healthz?$)/ + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + event.context.startedAt = Date.now() + }) + + nitroApp.hooks.hook('afterResponse', (event) => { + const req = event.node.req + const url = req.url ?? '' + if (SKIPPED.test(url)) { + return + } + const status = event.node.res.statusCode + const latencyMs = Date.now() - (event.context.startedAt ?? Date.now()) + log(status >= 500 ? 'ERROR' : status >= 400 ? 'WARNING' : 'INFO', `${req.method} ${url} ${status}`, { + httpRequest: { + requestMethod: req.method, + requestUrl: url, + status, + latency: `${latencyMs / 1000}s`, + userAgent: req.headers['user-agent'], + remoteIp: String(req.headers['x-forwarded-for'] ?? '').split(',')[0] || undefined + } + }, event) + }) +}) diff --git a/web/express-checkout/server/plugins/storage-polyfill.ts b/web/express-checkout/server/plugins/storage-polyfill.ts new file mode 100644 index 00000000..0423c635 --- /dev/null +++ b/web/express-checkout/server/plugins/storage-polyfill.ts @@ -0,0 +1,59 @@ +// SSR-safe polyfill for sessionStorage and localStorage +// Defined as a Nitro plugin to run at server startup, ensuring globals are available immediately + +import { log } from '../utils/logger' + +class ServerStorage implements Storage { + private data: Map = new Map() + + get length(): number { + return this.data.size + } + + clear(): void { + this.data.clear() + } + + getItem(key: string): string | null { + return this.data.get(key) ?? null + } + + key(index: number): string | null { + const keys = Array.from(this.data.keys()) + return keys[index] ?? null + } + + removeItem(key: string): void { + this.data.delete(key) + } + + setItem(key: string, value: string): void { + this.data.set(key, value) + } + + // Matches the index signature of the DOM Storage interface + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export default defineNitroPlugin(() => { + // Only polyfill if not already defined + if (!globalThis.sessionStorage) { + const mockSession = new ServerStorage() + // Define property to handle the "not defined" ReferenceError + Object.defineProperty(globalThis, 'sessionStorage', { + value: mockSession, + writable: true + }) + log('DEBUG', 'sessionStorage polyfill initialized') + } + + if (!globalThis.localStorage) { + const mockLocal = new ServerStorage() + Object.defineProperty(globalThis, 'localStorage', { + value: mockLocal, + writable: true + }) + log('DEBUG', 'localStorage polyfill initialized') + } +}) diff --git a/web/express-checkout/server/routes/pay-proxy/[...path].ts b/web/express-checkout/server/routes/pay-proxy/[...path].ts new file mode 100644 index 00000000..6c822aa5 --- /dev/null +++ b/web/express-checkout/server/routes/pay-proxy/[...path].ts @@ -0,0 +1,77 @@ +import type { H3Event } from 'h3' + +// Forwards /pay-proxy/ to the pay-api and adds the API key server-side, +// so the key is never exposed to the browser. + +const ALLOWED_PREFIXES = ['api/v1/', 'api/v2/'] +const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) +// Only these caller headers are forwarded; cookies, X-Forwarded-*, and any client X-Apikey are dropped +const FORWARDED_HEADERS = ['authorization', 'account-id', 'app-name', 'accept', 'accept-language', 'content-type'] +// Anonymous payers (payment-link guests) have no session token, so without one only the +// routes of the guest flow are reachable; everything else needs a signed-in user. +const GUEST_PATHS = [ + /^api\/v[12]\/payment-links\/[^/]+$/, + /^api\/v[12]\/payment-links\/[^/]+\/(transactions|receipts)$/, + /^api\/v[12]\/payment-requests\/\d+\/transactions\/[^/]+$/ +] +const BEARER = /^Bearer \S+$/ +// Encoded dots/slashes and backslashes can be normalized into traversal by URL parsing +const UNSAFE_PATH = /%2e|%2f|%5c|\\/i + +function resolveTarget(payApiUrl: string, rawPath: string, search: string): URL | undefined { + if (UNSAFE_PATH.test(rawPath) || rawPath.split('/').some(s => s === '..' || s === '.') + || !ALLOWED_PREFIXES.some(p => rawPath.startsWith(p))) { + return undefined + } + const base = new URL(payApiUrl.replace(/\/?$/, '/')) + const target = new URL(rawPath, base) + // Re-check after URL normalization so the request cannot leave the configured base + if (target.origin !== base.origin || !ALLOWED_PREFIXES.some(p => target.pathname.startsWith(base.pathname + p))) { + return undefined + } + target.search = search + return target +} + +function forwardedHeaders(event: H3Event, xApiKey: string): Record { + const headers: Record = {} + for (const name of FORWARDED_HEADERS) { + const value = getRequestHeader(event, name) + if (value) { + headers[name] = value + } + } + headers['x-apikey'] = xApiKey + return headers +} + +export default defineEventHandler(async (event) => { + const { payApiUrl, xApiKey } = useRuntimeConfig(event) + if (!payApiUrl) { + throw createError({ statusCode: 502, statusMessage: 'pay-api URL is not configured' }) + } + + if (!ALLOWED_METHODS.has(event.method)) { + throw createError({ statusCode: 405, statusMessage: 'Method not allowed' }) + } + + const rawPath = getRouterParam(event, 'path') ?? '' + const hasSession = BEARER.test(String(getRequestHeader(event, 'authorization') ?? '')) + if (!hasSession && !GUEST_PATHS.some(p => p.test(rawPath))) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + + const target = resolveTarget(payApiUrl, rawPath, getRequestURL(event).search) + if (!target) { + throw createError({ statusCode: 400, statusMessage: 'Invalid path' }) + } + + const raw = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(event.method) + ? await readRawBody(event, false) + : undefined + const body = raw ? new Uint8Array(raw) : undefined + + return sendProxy(event, target.href, { + fetchOptions: { method: event.method, body, headers: forwardedHeaders(event, xApiKey) } + }) +}) diff --git a/web/express-checkout/server/utils/logger.ts b/web/express-checkout/server/utils/logger.ts new file mode 100644 index 00000000..767cfd6e --- /dev/null +++ b/web/express-checkout/server/utils/logger.ts @@ -0,0 +1,34 @@ +import type { H3Event } from 'h3' + +type Severity = 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' + +const LEVELS: Severity[] = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] + +// Minimum level comes from the LOG_LEVEL env var (DEBUG, INFO, WARNING, ERROR); defaults to INFO +const configured = process.env.LOG_LEVEL?.toUpperCase() as Severity +const minLevel = LEVELS.indexOf(LEVELS.includes(configured) ? configured : 'INFO') + +// Cloud Run does not set the project id automatically; set it to enable trace correlation +const project = process.env.GOOGLE_CLOUD_PROJECT + +function traceOf(event?: H3Event): Record { + const header = event?.node?.req?.headers?.['x-cloud-trace-context'] + const traceId = typeof header === 'string' ? header.split('/')[0] : undefined + if (!traceId || !project) { + return {} + } + return { 'logging.googleapis.com/trace': `projects/${project}/traces/${traceId}` } +} + +export function log(severity: Severity, message: string, fields: Record = {}, event?: H3Event) { + if (LEVELS.indexOf(severity) < minLevel) { + return + } + const entry = JSON.stringify({ severity, message, ...fields, ...traceOf(event) }) + // Cloud Logging reads severity from the JSON payload regardless of stream + if (severity === 'ERROR' || severity === 'WARNING') { + console.error(entry) + } else { + console.info(entry) + } +} diff --git a/web/express-checkout/tests/unit/server/middleware/csp.spec.ts b/web/express-checkout/tests/unit/server/middleware/csp.spec.ts new file mode 100644 index 00000000..0c087ba4 --- /dev/null +++ b/web/express-checkout/tests/unit/server/middleware/csp.spec.ts @@ -0,0 +1,66 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime' +import type { H3Event } from 'h3' + +const { mockLog, runtimeConfig } = vi.hoisted(() => { + // Nitro auto-import that is not available in the test environment + (globalThis as Record).defineEventHandler = (handler: unknown) => handler + return { mockLog: vi.fn(), runtimeConfig: { cspExtra: undefined as string | undefined } } +}) + +vi.mock('../../../../server/utils/logger', () => ({ log: mockLog })) +mockNuxtImport('useRuntimeConfig', () => () => ({ ...runtimeConfig })) + +const setResponseHeader = vi.fn() + +async function loadMiddleware() { + // Reset the module-level "warned" flag between tests + vi.resetModules() + return (await import('../../../../server/middleware/csp')).default as unknown as (event: H3Event) => void +} + +describe('server/middleware/csp', () => { + const event = { id: 'evt' } as unknown as H3Event + + beforeEach(() => { + vi.stubGlobal('setResponseHeader', setResponseHeader) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sets the trimmed Content-Security-Policy header from runtime config', async () => { + runtimeConfig.cspExtra = " default-src 'self'; frame-ancestors 'none' " + const middleware = await loadMiddleware() + + middleware(event) + + expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-security-policy', + "default-src 'self'; frame-ancestors 'none'") + expect(mockLog).not.toHaveBeenCalled() + }) + + it.each([undefined, '', ' '])('warns only once and sets no header when cspExtra is %j', async (value) => { + runtimeConfig.cspExtra = value + const middleware = await loadMiddleware() + + middleware(event) + middleware(event) + middleware(event) + + expect(setResponseHeader).not.toHaveBeenCalled() + expect(mockLog).toHaveBeenCalledTimes(1) + expect(mockLog).toHaveBeenCalledWith('WARNING', expect.stringContaining('NUXT_CSP_EXTRA is not set')) + }) + + it('applies the header once configured, even after warning', async () => { + runtimeConfig.cspExtra = undefined + const middleware = await loadMiddleware() + middleware(event) + runtimeConfig.cspExtra = "default-src 'self'" + middleware(event) + + expect(mockLog).toHaveBeenCalledTimes(1) + expect(setResponseHeader).toHaveBeenCalledWith(event, 'content-security-policy', "default-src 'self'") + }) +}) diff --git a/web/express-checkout/tests/unit/server/plugins/error-logger.spec.ts b/web/express-checkout/tests/unit/server/plugins/error-logger.spec.ts new file mode 100644 index 00000000..202c77ab --- /dev/null +++ b/web/express-checkout/tests/unit/server/plugins/error-logger.spec.ts @@ -0,0 +1,75 @@ +import type { H3Event } from 'h3' + +const { mockLog } = vi.hoisted(() => { + // Nitro auto-import that is not available in the test environment + (globalThis as Record).defineNitroPlugin = (plugin: unknown) => plugin + return { mockLog: vi.fn() } +}) + +vi.mock('../../../../server/utils/logger', () => ({ log: mockLog })) + +type ErrorHook = (error: Error, ctx: { event?: H3Event }) => void + +async function setup() { + let errorHook: ErrorHook | undefined + const hookNames: string[] = [] + const nitroApp = { + hooks: { + hook: (name: string, fn: ErrorHook) => { + hookNames.push(name) + errorHook = fn + } + } + } + const plugin = (await import('../../../../server/plugins/error-logger')).default as unknown as ( + app: typeof nitroApp) => void + plugin(nitroApp) + expect(hookNames).toEqual(['error']) + return errorHook! +} + +describe('server/plugins/error-logger', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('logs the error with stack trace, service context and request details', async () => { + vi.stubEnv('K_SERVICE', 'pay-ui-prod') + vi.stubEnv('K_REVISION', 'rev-7') + const hook = await setup() + const error = new Error('boom') + const event = { + node: { req: { method: 'PUT', url: '/x?y=1', headers: { 'user-agent': 'ua' } } } + } as unknown as H3Event + + hook(error, { event }) + + expect(mockLog).toHaveBeenCalledWith('ERROR', 'boom', { + stack_trace: error.stack, + serviceContext: { service: 'pay-ui-prod', version: 'rev-7' }, + httpRequest: { requestMethod: 'PUT', requestUrl: '/x?y=1', userAgent: 'ua' } + }, event) + }) + + it('defaults the service name and omits request details when there is no event', async () => { + vi.stubEnv('K_SERVICE', undefined) + vi.stubEnv('K_REVISION', undefined) + const hook = await setup() + const error = new Error('no event') + + hook(error, {}) + + expect(mockLog).toHaveBeenCalledWith('ERROR', 'no event', { + stack_trace: error.stack, + serviceContext: { service: 'express-checkout', version: undefined }, + httpRequest: undefined + }, undefined) + }) + + it('omits request details when the event has no node request', async () => { + const hook = await setup() + const event = {} as H3Event + hook(new Error('x'), { event }) + expect(mockLog.mock.calls[0]![2]).toMatchObject({ httpRequest: undefined }) + }) +}) diff --git a/web/express-checkout/tests/unit/server/plugins/request-logger.spec.ts b/web/express-checkout/tests/unit/server/plugins/request-logger.spec.ts new file mode 100644 index 00000000..961cecf8 --- /dev/null +++ b/web/express-checkout/tests/unit/server/plugins/request-logger.spec.ts @@ -0,0 +1,125 @@ +import type { H3Event } from 'h3' + +const { mockLog } = vi.hoisted(() => { + // Nitro auto-import that is not available in the test environment + (globalThis as Record).defineNitroPlugin = (plugin: unknown) => plugin + return { mockLog: vi.fn() } +}) + +vi.mock('../../../../server/utils/logger', () => ({ log: mockLog })) + +type Hook = (event: H3Event) => void + +async function setup() { + const hooks: Record = {} + const nitroApp = { hooks: { hook: (name: string, fn: Hook) => { hooks[name] = fn } } } + const plugin = (await import('../../../../server/plugins/request-logger')).default as unknown as ( + app: typeof nitroApp) => void + plugin(nitroApp) + return hooks +} + +function fakeEvent(opts: { + url?: string + method?: string + status?: number + headers?: Record + startedAt?: number +}): H3Event { + return { + context: opts.startedAt === undefined ? {} : { startedAt: opts.startedAt }, + node: { + req: { url: opts.url, method: opts.method ?? 'GET', headers: opts.headers ?? {} }, + res: { statusCode: opts.status ?? 200 } + } + } as unknown as H3Event +} + +describe('server/plugins/request-logger', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('registers request and afterResponse hooks', async () => { + const hooks = await setup() + expect(Object.keys(hooks).sort()).toEqual(['afterResponse', 'request']) + }) + + it('stamps the request start time and logs latency, client IP and user agent', async () => { + const hooks = await setup() + const event = fakeEvent({ + url: '/api/thing?x=1', + method: 'POST', + status: 201, + headers: { 'user-agent': 'jest', 'x-forwarded-for': '10.0.0.1, 10.0.0.2' } + }) + + hooks.request!(event) + expect(event.context.startedAt).toBe(Date.now()) + vi.advanceTimersByTime(250) + hooks.afterResponse!(event) + + expect(mockLog).toHaveBeenCalledWith('INFO', 'POST /api/thing?x=1 201', { + httpRequest: { + requestMethod: 'POST', + requestUrl: '/api/thing?x=1', + status: 201, + latency: '0.25s', + userAgent: 'jest', + remoteIp: '10.0.0.1' + } + }, event) + }) + + it.each([ + [500, 'ERROR'], + [503, 'ERROR'], + [400, 'WARNING'], + [404, 'WARNING'], + [399, 'INFO'], + [200, 'INFO'] + ])('maps status %i to severity %s', async (status, severity) => { + const hooks = await setup() + hooks.afterResponse!(fakeEvent({ url: '/x', status, startedAt: Date.now() })) + expect(mockLog.mock.calls[0]![0]).toBe(severity) + }) + + it('uses zero latency, an empty URL and no IP when the data is missing', async () => { + const hooks = await setup() + const event = fakeEvent({ url: undefined }) + hooks.afterResponse!(event) + expect(mockLog).toHaveBeenCalledWith('INFO', 'GET 200', { + httpRequest: { + requestMethod: 'GET', + requestUrl: '', + status: 200, + latency: '0s', + userAgent: undefined, + remoteIp: undefined + } + }, event) + }) + + it.each([ + '/_nuxt/entry.js', + '/__nuxt_error', + '/favicon.ico', + '/health', + '/healthz' + ])('skips noisy URL %s', async (url) => { + const hooks = await setup() + hooks.afterResponse!(fakeEvent({ url })) + expect(mockLog).not.toHaveBeenCalled() + }) + + it.each(['/healthcheck', '/api/_nuxt/x', '/nuxt'])('does not skip %s', async (url) => { + const hooks = await setup() + hooks.afterResponse!(fakeEvent({ url })) + expect(mockLog).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/express-checkout/tests/unit/server/plugins/storage-polyfill.spec.ts b/web/express-checkout/tests/unit/server/plugins/storage-polyfill.spec.ts new file mode 100644 index 00000000..871b223d --- /dev/null +++ b/web/express-checkout/tests/unit/server/plugins/storage-polyfill.spec.ts @@ -0,0 +1,103 @@ +const { mockLog } = vi.hoisted(() => { + // Nitro auto-import that is not available in the test environment + (globalThis as Record).defineNitroPlugin = (plugin: unknown) => plugin + return { mockLog: vi.fn() } +}) + +vi.mock('../../../../server/utils/logger', () => ({ log: mockLog })) + +const STORAGE_KEYS = ['sessionStorage', 'localStorage'] as const +type StorageKey = typeof STORAGE_KEYS[number] + +describe('server/plugins/storage-polyfill', () => { + const originals = new Map() + let plugin: () => void + + beforeAll(async () => { + plugin = (await import('../../../../server/plugins/storage-polyfill')).default as unknown as () => void + }) + + beforeEach(() => { + for (const key of STORAGE_KEYS) { + originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)) + } + }) + + afterEach(() => { + for (const key of STORAGE_KEYS) { + const descriptor = originals.get(key) + if (descriptor) { + Object.defineProperty(globalThis, key, { ...descriptor, configurable: true }) + } else { + Reflect.deleteProperty(globalThis, key) + } + } + }) + + function removeStorage(key: StorageKey) { + Object.defineProperty(globalThis, key, { value: undefined, writable: true, configurable: true }) + } + + it('leaves existing browser storage untouched', () => { + const session = globalThis.sessionStorage + const local = globalThis.localStorage + + plugin() + + expect(globalThis.sessionStorage).toBe(session) + expect(globalThis.localStorage).toBe(local) + expect(mockLog).not.toHaveBeenCalled() + }) + + it('polyfills both storages when they are missing', () => { + STORAGE_KEYS.forEach(removeStorage) + + plugin() + + expect(globalThis.sessionStorage).toBeDefined() + expect(globalThis.localStorage).toBeDefined() + expect(globalThis.sessionStorage).not.toBe(globalThis.localStorage) + expect(mockLog).toHaveBeenCalledWith('DEBUG', 'sessionStorage polyfill initialized') + expect(mockLog).toHaveBeenCalledWith('DEBUG', 'localStorage polyfill initialized') + }) + + it.each(STORAGE_KEYS)('polyfills only %s when only it is missing', (key) => { + const other = STORAGE_KEYS.find(k => k !== key)! + const existing = globalThis[other] + removeStorage(key) + + plugin() + + expect(globalThis[key]).toBeDefined() + expect(globalThis[other]).toBe(existing) + expect(mockLog).toHaveBeenCalledTimes(1) + expect(mockLog).toHaveBeenCalledWith('DEBUG', `${key} polyfill initialized`) + }) + + it('provides a working in-memory Storage implementation', () => { + removeStorage('sessionStorage') + plugin() + const storage = globalThis.sessionStorage + + expect(storage.length).toBe(0) + expect(storage.getItem('missing')).toBeNull() + expect(storage.key(0)).toBeNull() + + storage.setItem('a', '1') + storage.setItem('b', '2') + storage.setItem('a', '3') + expect(storage.length).toBe(2) + expect(storage.getItem('a')).toBe('3') + expect(storage.key(0)).toBe('a') + expect(storage.key(1)).toBe('b') + expect(storage.key(2)).toBeNull() + + storage.removeItem('a') + expect(storage.getItem('a')).toBeNull() + expect(storage.length).toBe(1) + + storage.clear() + expect(storage.length).toBe(0) + expect(storage.getItem('b')).toBeNull() + }) +}) diff --git a/web/express-checkout/tests/unit/server/routes/pay-proxy.spec.ts b/web/express-checkout/tests/unit/server/routes/pay-proxy.spec.ts new file mode 100644 index 00000000..4fd391b2 --- /dev/null +++ b/web/express-checkout/tests/unit/server/routes/pay-proxy.spec.ts @@ -0,0 +1,203 @@ +import { mockNuxtImport } from '@nuxt/test-utils/runtime' +import type { H3Event } from 'h3' + +const { runtimeConfig } = vi.hoisted(() => { + // Nitro auto-imports are not available in the test environment; provide them before the handler module loads + const g = globalThis as Record + g.defineEventHandler = (handler: unknown) => handler + return { runtimeConfig: { payApiUrl: '' as string | undefined, xApiKey: 'server-key' } } +}) + +mockNuxtImport('useRuntimeConfig', () => () => ({ ...runtimeConfig })) + +const getRequestHeader = vi.fn() +const getRouterParam = vi.fn() +const getRequestURL = vi.fn() +const readRawBody = vi.fn() +const sendProxy = vi.fn() + +interface FakeEventOptions { + method?: string + path?: string | undefined + search?: string + headers?: Record + body?: Buffer | string | undefined +} + +function fakeEvent(options: FakeEventOptions = {}) { + const { method = 'GET', search = '', headers, body } = options + const path = 'path' in options ? options.path : 'api/v1/fees' + const requestHeaders: Record = headers ?? { authorization: 'Bearer token-123' } + getRequestHeader.mockImplementation((_e: H3Event, name: string) => requestHeaders[name.toLowerCase()]) + getRouterParam.mockImplementation((_e: H3Event, name: string) => (name === 'path' ? path : undefined)) + getRequestURL.mockReturnValue(new URL(`https://express-checkout.test/pay-proxy/x${search}`)) + readRawBody.mockResolvedValue(body) + return { method } as unknown as H3Event +} + +let handler: (event: H3Event) => Promise + +describe('server/routes/pay-proxy/[...path]', () => { + beforeAll(async () => { + handler = (await import('../../../../server/routes/pay-proxy/[...path]')).default as never + }) + + beforeEach(() => { + runtimeConfig.payApiUrl = 'https://pay-api.test' + runtimeConfig.xApiKey = 'server-key' + vi.stubGlobal('getRequestHeader', getRequestHeader) + vi.stubGlobal('getRouterParam', getRouterParam) + vi.stubGlobal('getRequestURL', getRequestURL) + vi.stubGlobal('readRawBody', readRawBody) + vi.stubGlobal('sendProxy', sendProxy) + sendProxy.mockResolvedValue('proxied') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('configuration and request guards', () => { + it.each([undefined, ''])('returns 502 when payApiUrl is %j', async (url) => { + runtimeConfig.payApiUrl = url + await expect(handler(fakeEvent())).rejects.toMatchObject({ statusCode: 502 }) + expect(sendProxy).not.toHaveBeenCalled() + }) + + it.each(['HEAD', 'OPTIONS', 'TRACE', 'CONNECT'])('returns 405 for method %s', async (method) => { + await expect(handler(fakeEvent({ method }))).rejects.toMatchObject({ statusCode: 405 }) + expect(sendProxy).not.toHaveBeenCalled() + }) + + it.each([ + 'api/v1/payment-links/tok123', + 'api/v1/payment-links/tok123/transactions', + 'api/v1/payment-links/tok123/receipts', + 'api/v1/payment-requests/42/transactions/t-1' + ])('lets a guest without a token reach %s', async (path) => { + await handler(fakeEvent({ headers: {}, path })) + expect(sendProxy).toHaveBeenCalledOnce() + }) + + it.each([ + 'api/v1/payment-requests/42', + 'api/v1/payment-requests/42/receipts', + 'api/v1/payment-links/tok123/redemption', + 'api/v1/payment-links/tok123/other', + 'api/v1/accounts/1/payment-info', + 'api/v1/documents' + ])('rejects a guest without a token on %s', async (path) => { + await expect(handler(fakeEvent({ headers: {}, path }))).rejects.toMatchObject({ statusCode: 401 }) + expect(sendProxy).not.toHaveBeenCalled() + }) + + it.each([ + ['missing header', {}], + ['non-bearer scheme', { authorization: 'Basic dXNlcjpwYXNz' }], + ['empty bearer token', { authorization: 'Bearer ' }], + ['token containing whitespace', { authorization: 'Bearer abc def' }], + ['lowercase scheme', { authorization: 'bearer abc' }] + ])('returns 401 for %s', async (_label, headers) => { + await expect(handler(fakeEvent({ headers }))).rejects.toMatchObject({ statusCode: 401 }) + expect(sendProxy).not.toHaveBeenCalled() + }) + }) + + describe('path validation', () => { + it.each([ + ['encoded dot', 'api/v1/%2e%2e/admin'], + ['uppercase encoded dot', 'api/v1/%2E%2E/admin'], + ['encoded slash', 'api/v1/fees%2f..%2fadmin'], + ['encoded backslash', 'api/v1/fees%5cadmin'], + ['raw backslash', 'api/v1\\..\\admin'], + ['dot-dot segment', 'api/v1/../../admin'], + ['single-dot segment', 'api/v1/./fees'], + ['disallowed prefix', 'api/v3/fees'], + ['missing version', 'api/fees'], + ['prefix without trailing slash', 'api/v1'], + ['absolute URL', 'https://evil.test/api/v1/x'], + ['empty path', ''], + ['missing path param', undefined], + // Tabs are stripped by the URL parser, so ".\t." becomes ".." only after normalization + ['traversal revealed by URL normalization', 'api/v1/.\t./x'] + ])('returns 400 for %s', async (_label, path) => { + await expect(handler(fakeEvent({ path }))).rejects.toMatchObject({ statusCode: 400 }) + expect(sendProxy).not.toHaveBeenCalled() + }) + }) + + describe('forwarding', () => { + it('proxies a GET request with only allow-listed headers and the server API key', async () => { + const event = fakeEvent({ + path: 'api/v1/fees/BCR', + search: '?a=1&b=two', + headers: { + 'authorization': 'Bearer token-123', + 'account-id': '42', + 'app-name': 'pay-ui', + 'accept': 'application/json', + 'accept-language': 'en', + 'content-type': 'application/json', + 'cookie': 'session=secret', + 'x-apikey': 'client-supplied', + 'x-forwarded-for': '1.2.3.4' + } + }) + + await expect(handler(event)).resolves.toBe('proxied') + + expect(readRawBody).not.toHaveBeenCalled() + expect(sendProxy).toHaveBeenCalledWith(event, 'https://pay-api.test/api/v1/fees/BCR?a=1&b=two', { + fetchOptions: { + method: 'GET', + body: undefined, + headers: { + 'authorization': 'Bearer token-123', + 'account-id': '42', + 'app-name': 'pay-ui', + 'accept': 'application/json', + 'accept-language': 'en', + 'content-type': 'application/json', + 'x-apikey': 'server-key' + } + } + }) + }) + + it('omits absent headers and accepts api/v2 paths', async () => { + const event = fakeEvent({ path: 'api/v2/statements' }) + + await handler(event) + + const [, href, options] = sendProxy.mock.calls[0]! + expect(href).toBe('https://pay-api.test/api/v2/statements') + expect(options.fetchOptions.headers).toEqual({ 'authorization': 'Bearer token-123', 'x-apikey': 'server-key' }) + }) + + it.each([ + 'https://pay-api.test/pay-base', + 'https://pay-api.test/pay-base/' + ])('keeps the base path of the configured pay-api URL %s', async (url) => { + runtimeConfig.payApiUrl = url + await handler(fakeEvent({ path: 'api/v1/fees' })) + expect(sendProxy.mock.calls[0]![1]).toBe('https://pay-api.test/pay-base/api/v1/fees') + }) + + it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('reads and forwards the raw body for %s', async (method) => { + const event = fakeEvent({ method, body: Buffer.from('{"x":1}') }) + + await handler(event) + + expect(readRawBody).toHaveBeenCalledWith(event, false) + const { fetchOptions } = sendProxy.mock.calls[0]![2] + expect(fetchOptions.method).toBe(method) + expect(fetchOptions.body).toBeInstanceOf(Uint8Array) + expect(Array.from(fetchOptions.body as Uint8Array)).toEqual([123, 34, 120, 34, 58, 49, 125]) + }) + + it.each([undefined, ''])('sends no body when the raw body is %j', async (body) => { + await handler(fakeEvent({ method: 'POST', body })) + expect(sendProxy.mock.calls[0]![2].fetchOptions.body).toBeUndefined() + }) + }) +}) diff --git a/web/express-checkout/tests/unit/server/utils/logger.spec.ts b/web/express-checkout/tests/unit/server/utils/logger.spec.ts new file mode 100644 index 00000000..004584eb --- /dev/null +++ b/web/express-checkout/tests/unit/server/utils/logger.spec.ts @@ -0,0 +1,108 @@ +import type { H3Event } from 'h3' + +type LoggerModule = typeof import('../../../../server/utils/logger') + +async function loadLogger(env: { LOG_LEVEL?: string, GOOGLE_CLOUD_PROJECT?: string } = {}): Promise { + vi.resetModules() + for (const key of ['LOG_LEVEL', 'GOOGLE_CLOUD_PROJECT'] as const) { + if (env[key] === undefined) { + vi.stubEnv(key, undefined) + } else { + vi.stubEnv(key, env[key]) + } + } + return import('../../../../server/utils/logger') +} + +function eventWithTrace(header: unknown): H3Event { + return { node: { req: { headers: { 'x-cloud-trace-context': header } } } } as unknown as H3Event +} + +function lastJson(spy: ReturnType) { + return JSON.parse(spy.mock.calls.at(-1)![0] as string) +} + +describe('server/utils/logger', () => { + let infoSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('defaults to INFO: drops DEBUG and writes INFO to stdout as structured JSON', async () => { + const { log } = await loadLogger() + + log('DEBUG', 'hidden') + expect(infoSpy).not.toHaveBeenCalled() + + log('INFO', 'hello', { foo: 'bar' }) + expect(infoSpy).toHaveBeenCalledTimes(1) + expect(lastJson(infoSpy)).toEqual({ severity: 'INFO', message: 'hello', foo: 'bar' }) + expect(errorSpy).not.toHaveBeenCalled() + }) + + it.each(['WARNING', 'ERROR'] as const)('writes %s entries to stderr', async (severity) => { + const { log } = await loadLogger() + log(severity, 'problem') + expect(infoSpy).not.toHaveBeenCalled() + expect(lastJson(errorSpy)).toEqual({ severity, message: 'problem' }) + }) + + it('honours a lower-case LOG_LEVEL of debug', async () => { + const { log } = await loadLogger({ LOG_LEVEL: 'debug' }) + log('DEBUG', 'visible') + expect(lastJson(infoSpy)).toEqual({ severity: 'DEBUG', message: 'visible' }) + }) + + it('suppresses entries below a configured ERROR level', async () => { + const { log } = await loadLogger({ LOG_LEVEL: 'ERROR' }) + log('INFO', 'no') + log('WARNING', 'no') + expect(infoSpy).not.toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() + log('ERROR', 'yes') + expect(errorSpy).toHaveBeenCalledTimes(1) + }) + + it('falls back to INFO for an unknown LOG_LEVEL', async () => { + const { log } = await loadLogger({ LOG_LEVEL: 'verbose' }) + log('DEBUG', 'hidden') + expect(infoSpy).not.toHaveBeenCalled() + log('INFO', 'shown') + expect(infoSpy).toHaveBeenCalledTimes(1) + }) + + it('adds the Cloud Trace id when the project and trace header are present', async () => { + const { log } = await loadLogger({ GOOGLE_CLOUD_PROJECT: 'my-proj' }) + log('INFO', 'traced', {}, eventWithTrace('abc123/456;o=1')) + expect(lastJson(infoSpy)).toEqual({ + 'severity': 'INFO', + 'message': 'traced', + 'logging.googleapis.com/trace': 'projects/my-proj/traces/abc123' + }) + }) + + it.each([ + ['no event', undefined], + ['an event without node', {} as H3Event], + ['a missing header', eventWithTrace(undefined)], + ['a non-string header', eventWithTrace(['abc/1'])], + ['an empty trace id', eventWithTrace('/1')] + ])('omits the trace for %s', async (_label, event) => { + const { log } = await loadLogger({ GOOGLE_CLOUD_PROJECT: 'my-proj' }) + log('INFO', 'untraced', {}, event) + expect(lastJson(infoSpy)).not.toHaveProperty('logging.googleapis.com/trace') + }) + + it('omits the trace when GOOGLE_CLOUD_PROJECT is not set', async () => { + const { log } = await loadLogger() + log('INFO', 'untraced', {}, eventWithTrace('abc123/1')) + expect(lastJson(infoSpy)).toEqual({ severity: 'INFO', message: 'untraced' }) + }) +}) diff --git a/web/express-checkout/vitest.config.ts b/web/express-checkout/vitest.config.ts index 2f551ebd..931e1c24 100644 --- a/web/express-checkout/vitest.config.ts +++ b/web/express-checkout/vitest.config.ts @@ -13,14 +13,13 @@ export default defineVitestConfig({ coverage: { provider: 'v8', reportsDirectory: './tests/unit/coverage', + allowExternal: true, + // Vitest's root is app/, so relative globs match nothing; ../server lives outside the root include: [ - 'app/pages/**', - 'app/components/**', - 'app/composables/**', - 'app/middleware/**', - 'app/stores/**', - 'app/utils/**' - ] + '**/express-checkout/app/**/*.{ts,vue}', + '**/express-checkout/server/**/*.ts' + ], + exclude: ['**/*.d.ts'] }, setupFiles: './tests/unit/setup.ts', globals: true, From 5d99b9525015f7a2c5d75d15147c69d6f5ff7aaf Mon Sep 17 00:00:00 2001 From: pwei1018 Date: Thu, 22 Oct 2026 09:13:22 -0700 Subject: [PATCH 2/2] feat: update CI/CD workflows to include hotfix branches and redeploy option --- .github/workflows/express-checkout-cd.yml | 16 ++++++++++++---- .github/workflows/express-checkout-ci.yml | 1 + .github/workflows/pay-ui-cd.yml | 13 +++++++++++-- .github/workflows/pay-ui-ci.yml | 1 + 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/express-checkout-cd.yml b/.github/workflows/express-checkout-cd.yml index 17aed8be..3a8e626b 100644 --- a/.github/workflows/express-checkout-cd.yml +++ b/.github/workflows/express-checkout-cd.yml @@ -1,10 +1,11 @@ -name: EXPRESS-CHECKOUT GCP CD +name: EXPRESS-CHECKOUT CD on: push: branches: - main - - feature-express-checkout + - feature* + - hotfix* paths: - "web/express-checkout/**" - ".github/workflows/express-checkout-cd.yml" @@ -19,10 +20,17 @@ on: - test - sandbox - prod - + redeploy: + description: "Redeploy Application" + required: true + type: choice + options: + - "false" + - "true" + jobs: express-checkout-cd: - uses: bcgov/bcregistry-sre/.github/workflows/frontend-cd.yaml@main + uses: bcgov/bcregistry-sre/.github/workflows/backend-cd.yaml@main with: target: ${{ inputs.target }} app_name: "express-checkout" diff --git a/.github/workflows/express-checkout-ci.yml b/.github/workflows/express-checkout-ci.yml index 8aa75a1a..b07f93e1 100644 --- a/.github/workflows/express-checkout-ci.yml +++ b/.github/workflows/express-checkout-ci.yml @@ -5,6 +5,7 @@ on: branches: - main - feature* + - hotfix* paths: - "web/express-checkout/**" - ".github/workflows/express-checkout-ci.yml" diff --git a/.github/workflows/pay-ui-cd.yml b/.github/workflows/pay-ui-cd.yml index e7de31c0..19110a33 100644 --- a/.github/workflows/pay-ui-cd.yml +++ b/.github/workflows/pay-ui-cd.yml @@ -4,6 +4,8 @@ on: push: branches: - main + - feature* + - hotfix* paths: - "web/pay-ui/**" - ".github/workflows/pay-ui-cd.yml" @@ -18,10 +20,17 @@ on: - test - sandbox - prod - + redeploy: + description: "Redeploy Application" + required: true + type: choice + options: + - "false" + - "true" + jobs: pay-ui-cd: - uses: bcgov/bcregistry-sre/.github/workflows/frontend-cd.yaml@main + uses: bcgov/bcregistry-sre/.github/workflows/backend-cd.yaml@main with: target: ${{ inputs.target }} app_name: "pay-ui" diff --git a/.github/workflows/pay-ui-ci.yml b/.github/workflows/pay-ui-ci.yml index 2e52ceeb..6006f748 100644 --- a/.github/workflows/pay-ui-ci.yml +++ b/.github/workflows/pay-ui-ci.yml @@ -5,6 +5,7 @@ on: branches: - main - feature* + - hotfix* paths: - "web/pay-ui/**" - ".github/workflows/pay-ui-ci.yml"