From 70d2ee175d8a61fe5575193be32cf8028db10b27 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 25 Mar 2026 12:15:06 +0000 Subject: [PATCH] test(phase22): add auth redirect safety and i18n route parity checks --- memory-bank/change-log.md | 44 ++++++ tests/phase22/auth-redirect-safety.test.cjs | 167 ++++++++++++++++++++ tests/phase22/i18n-route-parity.test.cjs | 65 ++++++++ tests/phase22/index.test.cjs | 4 + 4 files changed, 280 insertions(+) create mode 100644 tests/phase22/auth-redirect-safety.test.cjs create mode 100644 tests/phase22/i18n-route-parity.test.cjs diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 28faf449..269a4093 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -2681,3 +2681,47 @@ Validation: Follow-ups: - Closure for this condensed stream complete; any further work should be a separate expansion stream (e.g., additional service-level phase22 coverage breadth). + +--- + +### CL-075: TASK22260 sequence-A step3 completion slice — auth redirect safety + EN/CY route parity automation + +date: 2026-03-25 +author: Cline +scope: `tests/phase22/{auth-redirect-safety,i18n-route-parity,index}.test.cjs` +type: change +rationale: Continue on this branch to complete the remaining sequence-A step3 gaps by adding explicit automated checks for auth callback/redirect safety and EN/CY route parity. +impact: Improves confidence in auth redirect safety behavior and bilingual rewrite parity with targeted, low-risk regression checks and no runtime code changes. +status: completed + +Summary: + +- Added `tests/phase22/auth-redirect-safety.test.cjs`: + - validates locale resolution precedence (`query -> body -> cookie -> default en`) + - validates redirect callback behavior for: + - relative URL to same base + - same-origin absolute URL passthrough + - external URL rewritten to locale-safe base origin with preserved path/query +- Added `tests/phase22/i18n-route-parity.test.cjs`: + - asserts presence of required CY rewrite aliases for auth/policy routes in `next.config.js` + - includes checks for signin/email/error/verify-request + privacy/accessibility/terms routes +- Updated `tests/phase22/index.test.cjs` to include both new suites in aggregate phase22 execution. + +Validation: + +- `node tests/phase22/auth-redirect-safety.test.cjs` -> pass (4/4) +- `node tests/phase22/i18n-route-parity.test.cjs` -> pass (1/1) +- `node tests/phase22/index.test.cjs` -> pass + - core-token: 2/2 + - client-utils: 6/6 + - file-client: 4/4 + - case-service: 4/4 + - portal-service: 4/4 + - auth-redirect: 4/4 + - i18n-route: 1/1 + - phase22 combined: pass +- `npm run lint` -> warnings only (pre-existing `react-hooks/exhaustive-deps`; no new lint errors) + +Follow-ups: + +- Sequence A step3 targeted gaps are now covered; further test expansion should be treated as new scope (e.g., deeper end-to-end journey assertions). diff --git a/tests/phase22/auth-redirect-safety.test.cjs b/tests/phase22/auth-redirect-safety.test.cjs new file mode 100644 index 00000000..7020109c --- /dev/null +++ b/tests/phase22/auth-redirect-safety.test.cjs @@ -0,0 +1,167 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const loadAuthInternals = () => { + const filePath = path.join( + __dirname, + "..", + "..", + "pages", + "api", + "auth", + "[...nextauth].js" + ); + + let source = fs.readFileSync(filePath, "utf8"); + source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); + source = source.replace( + /export default NextAuthPEDW;\s*$/, + "module.exports = { appendParamsAndPathToNewUrl, resolveLocale, authOptions, NextAuthPEDW };" + ); + + const context = { + module: { exports: {} }, + exports: {}, + require: (id) => { + if (id === "notifications-node-client") { + return { + NotifyClient: function NotifyClient() { + return { + sendEmail: async () => ({}) + }; + } + }; + } + + throw new Error(`Unexpected require in auth test: ${id}`); + }, + URL, + URLSearchParams, + process: { + env: { + NEXTAUTH_URL: "https://english.example", + CY_API_ROOT: "https://welsh.example", + NEXTAUTH_SECRET: "test-secret" + } + }, + PrismaAdapter: () => ({}), + PrismaClient: function PrismaClient() { + return {}; + }, + NextAuth: () => ({}), + EmailProvider: () => ({}), + consoleLogger: () => {}, + console: { + log: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + } + }; + + vm.runInNewContext(source, context, { filename: filePath }); + return context.module.exports; +}; + +test("auth/resolveLocale prefers query then body then cookie then default", async () => { + const mod = loadAuthInternals(); + + assert.strictEqual( + mod.resolveLocale({ + query: { locale: "cy" }, + body: { locale: "en" }, + cookies: { pedw_locale: "en" } + }), + "cy" + ); + + assert.strictEqual( + mod.resolveLocale({ + body: { locale: "cy" }, + cookies: { pedw_locale: "en" } + }), + "cy" + ); + + assert.strictEqual( + mod.resolveLocale({ + cookies: { pedw_locale: "cy" } + }), + "cy" + ); + + assert.strictEqual(mod.resolveLocale({}), "en"); +}); + +test("auth/redirect callback keeps relative URLs on same base", async () => { + const mod = loadAuthInternals(); + const req = { query: { locale: "en" }, body: {}, cookies: {} }; + + const options = mod.authOptions(req, {}); + const result = options.callbacks.redirect({ + url: "/account/register", + baseUrl: "https://pedw.example" + }); + + assert.strictEqual(result, "https://pedw.example/account/register"); +}); + +test("auth/redirect callback keeps same-origin absolute URLs unchanged", async () => { + const mod = loadAuthInternals(); + const req = { query: { locale: "en" }, body: {}, cookies: {} }; + + const options = mod.authOptions(req, {}); + const result = options.callbacks.redirect({ + url: "https://pedw.example/auth/verify-request?token=abc", + baseUrl: "https://pedw.example" + }); + + assert.strictEqual( + result, + "https://pedw.example/auth/verify-request?token=abc" + ); +}); + +test("auth/redirect callback rewrites external URL to locale-safe base origin", async () => { + const mod = loadAuthInternals(); + const req = { query: { locale: "cy" }, body: {}, cookies: {} }; + + const options = mod.authOptions(req, {}); + const result = options.callbacks.redirect({ + url: "https://malicious.example/auth/signin?callbackUrl=%2Fdashboard&token=abc", + baseUrl: "https://pedw.example" + }); + + const parsed = new URL(result); + assert.strictEqual(parsed.origin, "https://welsh.example"); + assert.strictEqual(parsed.pathname, "/auth/signin"); + assert.strictEqual(parsed.searchParams.get("callbackUrl"), "/dashboard"); + assert.strictEqual(parsed.searchParams.get("token"), "abc"); +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 auth-redirect tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/i18n-route-parity.test.cjs b/tests/phase22/i18n-route-parity.test.cjs new file mode 100644 index 00000000..683ab513 --- /dev/null +++ b/tests/phase22/i18n-route-parity.test.cjs @@ -0,0 +1,65 @@ +const assert = require("assert"); +const path = require("path"); + +const tests = []; +const test = (name, fn) => tests.push({ name, fn }); + +const loadRewrites = async () => { + const configPath = path.join(__dirname, "..", "..", "next.config.js"); + // eslint-disable-next-line global-require, import/no-dynamic-require + const nextConfig = require(configPath); + return nextConfig.rewrites(); +}; + +test("i18n/rewrites include required CY aliases for auth and policy routes", async () => { + const rewrites = await loadRewrites(); + + const requiredPairs = [ + { source: "/awd/mewngofnodi", destination: "/auth/signin" }, + { source: "/awd/mewngofnodi/ebost", destination: "/auth/signin/email" }, + { source: "/awd/gwall", destination: "/auth/error" }, + { source: "/awd/gwirio-cais", destination: "/auth/verify-request" }, + { source: "/preifatrwydd", destination: "/privacy" }, + { source: "/hygyrchedd", destination: "/accessibility" }, + { + source: "/telerau-ac-amodau", + destination: "/terms-and-conditions" + } + ]; + + for (const pair of requiredPairs) { + const found = rewrites.some( + (entry) => + entry.source === pair.source && + entry.destination === pair.destination + ); + + assert.strictEqual( + found, + true, + `Missing required CY rewrite ${pair.source} -> ${pair.destination}` + ); + } +}); + +const run = async () => { + let passed = 0; + + for (const currentTest of tests) { + await currentTest.fn(); + passed += 1; + } + + console.log( + `Phase 22 i18n-route tests passed (${passed}/${tests.length}).` + ); +}; + +module.exports = run; + +if (require.main === module) { + run().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/tests/phase22/index.test.cjs b/tests/phase22/index.test.cjs index 7a2102d6..f5f3f3eb 100644 --- a/tests/phase22/index.test.cjs +++ b/tests/phase22/index.test.cjs @@ -3,6 +3,8 @@ const runClientUtilsTests = require("./client-utils-behaviour.test.cjs"); const runFileClientTests = require("./file-client-behaviour.test.cjs"); const runCaseServiceTests = require("./case-service-behaviour.test.cjs"); const runPortalServiceTests = require("./portal-service-behaviour.test.cjs"); +const runAuthRedirectSafetyTests = require("./auth-redirect-safety.test.cjs"); +const runI18nRouteParityTests = require("./i18n-route-parity.test.cjs"); const run = async () => { await runCoreTokenTests(); @@ -10,6 +12,8 @@ const run = async () => { await runFileClientTests(); await runCaseServiceTests(); await runPortalServiceTests(); + await runAuthRedirectSafetyTests(); + await runI18nRouteParityTests(); console.log("Phase 22 combined suite passed."); };