From e1f8fb5c96c01857d3ef9486b942192ee8f4629b Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 6 Nov 2026 06:42:16 +0000 Subject: [PATCH] Revert "security: enforce authenticated CRM contact ownership" This reverts commit e13700ab654fb259195d6bc334c603c5ecd9c423. --- lib/auth/resolveCrmLocale.js | 42 --- pages/api/auth/[...nextauth].js | 27 +- pages/api/auth/resolve-locale.js | 33 ++- pages/api/endpoint/getportallogin_api.js | 49 +--- .../endpoint-handler-contract.test.cjs | 243 +++++------------- tests/phase22/auth-redirect-safety.test.cjs | 7 +- .../preauth-locale-resolution.test.cjs | 242 ----------------- 7 files changed, 126 insertions(+), 517 deletions(-) delete mode 100644 lib/auth/resolveCrmLocale.js delete mode 100644 tests/phase22/preauth-locale-resolution.test.cjs diff --git a/lib/auth/resolveCrmLocale.js b/lib/auth/resolveCrmLocale.js deleted file mode 100644 index 0677ab16..00000000 --- a/lib/auth/resolveCrmLocale.js +++ /dev/null @@ -1,42 +0,0 @@ -import { azureHeadersPaged } from "../../actions/core/headers"; -import { escapeODataString } from "../../actions/core/guards"; -import { consoleLogger } from "../../actions/core/logger"; -import { relayGetData } from "../../pages/api/middleware/relayForwarding"; -import { RELAY_POLICY_STRICT_LOGIN } from "../../pages/api/middleware/relayPolicyPresets"; - -const WELSH_LANGUAGE_CODE = 846040000; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -export const resolveCrmLocale = async (email) => { - if (typeof email !== "string") return null; - - const normalizedEmail = email.trim().toLowerCase(); - - if (!EMAIL_PATTERN.test(normalizedEmail)) return null; - - const queryUrl = - "contacts?$filter=emailaddress1 eq '" + - escapeODataString(normalizedEmail) + - "' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage"; - - try { - const { data } = await relayGetData({ - queryUrl, - requestOptionsBuilder: (accessToken) => - azureHeadersPaged(accessToken), - relayPolicy: RELAY_POLICY_STRICT_LOGIN - }); - const preferredLanguage = data?.value?.[0]?.pinswg_preferredlanguage; - - if (preferredLanguage === WELSH_LANGUAGE_CODE) return "cy"; - if (preferredLanguage != null) return "en"; - - return null; - } catch (error) { - consoleLogger({ - name: "ResolveCrmLocale", - message: "CRM locale lookup failed" - }); - return null; - } -}; \ No newline at end of file diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index f953bf13..ac88da54 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -14,8 +14,8 @@ import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaClient } from "@prisma/client"; import NextAuth from "next-auth"; import EmailProvider from "../../../lib/auth/email-provider"; -import { resolveCrmLocale } from "../../../lib/auth/resolveCrmLocale"; import { consoleLogger, redactSensitive } from "../../../actions/core/logger"; +import { getPortalLogin } from "../../../actions/services/accountService"; const prisma = new PrismaClient(); @@ -53,6 +53,8 @@ const createVerificationTokenSynchronizationAdapter = ( }; }; +const WELSH_LANGUAGE_CODE = 846040000; + const authDiagnostic = (event, metadata = {}) => { try { const safeMeta = JSON.parse(JSON.stringify(metadata)); @@ -82,6 +84,29 @@ const appendParamsAndPathToNewUrl = (fromUrl, toUrl) => { } }; +const resolveCrmLocale = async (email) => { + if (!email) return null; + + try { + const portalUserObj = await getPortalLogin(email); + const preferredLanguage = + portalUserObj?.value?.[0]?.pinswg_preferredlanguage; + + if (preferredLanguage === WELSH_LANGUAGE_CODE) { + return "cy"; + } + + if (preferredLanguage != null) { + return "en"; + } + + return null; + } catch (error) { + consoleLogger(error); + return null; + } +}; + const resolveEffectiveLocale = async (req, email) => { const crmLocale = await resolveCrmLocale(email); if (crmLocale) { diff --git a/pages/api/auth/resolve-locale.js b/pages/api/auth/resolve-locale.js index f029dce2..f89bf9df 100644 --- a/pages/api/auth/resolve-locale.js +++ b/pages/api/auth/resolve-locale.js @@ -1,3 +1,8 @@ +import { getPortalLogin } from "../../../actions/services/accountService"; +import { consoleLogger } from "../../../actions/core/logger"; + +const WELSH_LANGUAGE_CODE = 846040000; + const resolveRequestLocale = (req) => req?.query?.locale || req?.body?.locale || @@ -9,8 +14,30 @@ export default async function handler(req, res) { return res.status(405).json({ message: "Method not allowed" }); } - const requestedLocale = resolveRequestLocale(req); - const locale = requestedLocale === "cy" ? "cy" : "en"; + const email = String(req.body?.email || "") + .trim() + .toLowerCase(); + const sessionLocale = resolveRequestLocale(req); - return res.status(200).json({ locale }); + if (!email) { + return res.status(200).json({ locale: sessionLocale }); + } + + try { + const portalUserObj = await getPortalLogin(email); + const preferredLanguage = + portalUserObj?.value?.[0]?.pinswg_preferredlanguage; + + const locale = + preferredLanguage === WELSH_LANGUAGE_CODE + ? "cy" + : preferredLanguage != null + ? "en" + : sessionLocale; + + return res.status(200).json({ locale }); + } catch (error) { + consoleLogger(error); + return res.status(200).json({ locale: sessionLocale }); + } } diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index c690be08..00871021 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -17,61 +17,20 @@ */ import { azureHeadersPaged } from "../../../actions/core/headers"; -import { escapeODataString } from "../../../actions/core/guards"; import { hashAPIPath } from "../../../actions/core/hash"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "../auth/[...nextauth]"; import { respondError } from "../middleware/apiResponse"; import { relayGet } from "../middleware/relayForwarding"; import { RELAY_POLICY_STRICT_LOGIN } from "../middleware/relayPolicyPresets"; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+$/; - export default async function ApiProxy(req, res) { - if (req.method !== "GET") { - return respondError(res, { - status: 405, - code: "METHOD_NOT_ALLOWED", - message: "Only GET is supported" - }); - } - - const session = await getServerSession(req, res, authOptions(req, res)); - const authenticatedEmail = session?.user?.email; - - if ( - typeof authenticatedEmail !== "string" || - !EMAIL_PATTERN.test(authenticatedEmail.trim()) - ) { - return respondError(res, { - status: 401, - code: "UNAUTHENTICATED", - message: "Authentication required" - }); - } - const emailAddress = req.query.emailAddress; const checkHash = req.query.hash; - if ( - typeof emailAddress !== "string" || - !EMAIL_PATTERN.test(emailAddress.trim()) - ) { + if (typeof emailAddress !== "string" || emailAddress.trim().length === 0) { return respondError(res, { status: 400, - code: "INVALID_EMAIL_ADDRESS", - message: "emailAddress must be a valid email address" - }); - } - - const normalizedAuthenticatedEmail = authenticatedEmail.trim().toLowerCase(); - const normalizedRequestedEmail = emailAddress.trim().toLowerCase(); - - if (normalizedRequestedEmail !== normalizedAuthenticatedEmail) { - return respondError(res, { - status: 403, - code: "FORBIDDEN", - message: "Request not permitted" + code: "EMAIL_ADDRESS_REQUIRED", + message: "emailAddress is required" }); } @@ -104,7 +63,7 @@ export default async function ApiProxy(req, res) { const queryUrl = "contacts?$filter=emailaddress1 eq '" + - escapeODataString(normalizedAuthenticatedEmail) + + emailAddress + "' and statuscode eq 1&$count=true&$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname"; const relayPolicy = RELAY_POLICY_STRICT_LOGIN; diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index 3821f9e2..6c3b2f0f 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -323,157 +323,64 @@ test("getlogin passes strict relayPolicy overrides to relayGet", async () => { }); }); -const createPortalLoginFixture = (overrides = {}) => { - const calls = { - session: [], - hash: [], - relay: [] - }; - const session = Object.prototype.hasOwnProperty.call(overrides, "session") - ? overrides.session - : { user: { email: "user@test.local" } }; - +test("getportallogin returns HASH_REQUIRED when hash missing", async () => { const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { respondError: respondErrorMock, respondSuccess: respondSuccessMock, - getServerSession: async (...args) => { - calls.session.push(args); - return session; - }, - authOptions: (req, res) => ({ req, res }), - relayGet: async (options) => { - calls.relay.push(options); - if (overrides.relayGet) return overrides.relayGet(options); - return respondSuccessMock(options.res, { value: [] }); - }, - hashAPIPath: (input) => { - calls.hash.push(input); - if (overrides.hashAPIPath) return overrides.hashAPIPath(input); - return "&hash=expected"; - }, - escapeODataString: (value) => value.trim().replace(/'/g, "''"), - azureHeadersPaged: (accessToken) => ({ accessToken }) - }); - - return { mod, calls }; -}; - -const invokePortalLogin = async ( - fixture, - query = { emailAddress: "user@test.local", hash: "expected" }, - method = "GET" -) => { - const req = { method, query }; - const res = createRes(); - await fixture.mod.default(req, res); - - return { req, res }; -}; - -test("getportallogin rejects unsupported methods before authentication", async () => { - const fixture = createPortalLoginFixture(); - const { res } = await invokePortalLogin(fixture, undefined, "POST"); - - assert.strictEqual(res.state.statusCode, 405); - assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED"); - assert.strictEqual(fixture.calls.session.length, 0); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin rejects missing session", async () => { - const fixture = createPortalLoginFixture({ session: null }); - const { req, res } = await invokePortalLogin(fixture); - - assert.strictEqual(res.state.statusCode, 401); - assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED"); - assert.strictEqual(fixture.calls.session[0][0], req); - assert.strictEqual(fixture.calls.session[0][1], res); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin rejects session without email", async () => { - const fixture = createPortalLoginFixture({ - session: { user: { id: "application-user" } } - }); - const { res } = await invokePortalLogin(fixture); - - assert.strictEqual(res.state.statusCode, 401); - assert.strictEqual(res.state.jsonBody.error.code, "UNAUTHENTICATED"); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin rejects malformed requested email", async () => { - const fixture = createPortalLoginFixture(); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "not-an-email", - hash: "expected" - }); - - assert.strictEqual(res.state.statusCode, 400); - assert.strictEqual(res.state.jsonBody.error.code, "INVALID_EMAIL_ADDRESS"); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin rejects another user's email before CRM access", async () => { - const fixture = createPortalLoginFixture(); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "other@test.local", - hash: "expected" - }); - - assert.strictEqual(res.state.statusCode, 403); - assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); - assert.strictEqual(fixture.calls.hash.length, 0); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin valid HMAC for another email does not bypass ownership", async () => { - const fixture = createPortalLoginFixture({ + relayGet: async () => ({}), hashAPIPath: (input) => - input.includes("other%40test.local") || - input.includes("other@test.local") - ? "&hash=valid-other-user-hash" - : "&hash=unexpected" - }); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "other@test.local", - hash: "valid-other-user-hash" + input && input.startsWith("/api/endpoint/getportallogin_api") + ? "&hash=expected" + : "&hash=relay", + azureHeadersPaged: () => ({}) }); - assert.strictEqual(res.state.statusCode, 403); - assert.strictEqual(res.state.jsonBody.error.code, "FORBIDDEN"); - assert.strictEqual(fixture.calls.relay.length, 0); -}); - -test("getportallogin returns HASH_REQUIRED when hash missing", async () => { - const fixture = createPortalLoginFixture(); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "user@test.local" - }); + const req = { query: { emailAddress: "user@test.local" } }; + const res = createRes(); + await mod.default(req, res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual(res.state.jsonBody.error.code, "HASH_REQUIRED"); - assert.strictEqual(fixture.calls.relay.length, 0); }); test("getportallogin returns INVALID_HASH when hash mismatch", async () => { - const fixture = createPortalLoginFixture(); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "user@test.local", - hash: "wrong" + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + relayGet: async () => ({}), + hashAPIPath: (input) => + input && input.startsWith("/api/endpoint/getportallogin_api") + ? "&hash=expected" + : "&hash=relay", + azureHeadersPaged: () => ({}) }); + const req = { query: { emailAddress: "user@test.local", hash: "wrong" } }; + const res = createRes(); + await mod.default(req, res); + assert.strictEqual(res.state.statusCode, 400); assert.strictEqual(res.state.jsonBody.error.code, "INVALID_HASH"); - assert.strictEqual(fixture.calls.relay.length, 0); }); test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => { - const fixture = createPortalLoginFixture({ + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, relayGet: async ({ res, errorResponse }) => - respondErrorMock(res, errorResponse) + respondErrorMock(res, errorResponse), + hashAPIPath: (input) => + input && input.startsWith("/api/endpoint/getportallogin_api") + ? "&hash=expected" + : "&hash=relay", + azureHeadersPaged: () => ({}) }); - const { res } = await invokePortalLogin(fixture); + + const req = { + query: { emailAddress: "user@test.local", hash: "expected" } + }; + const res = createRes(); + await mod.default(req, res); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual( @@ -484,13 +391,26 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => test("getportallogin passes strict relayPolicy overrides to relayGet", async () => { let capturedRelayPolicy = null; - const fixture = createPortalLoginFixture({ + + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, relayGet: async ({ relayPolicy, res }) => { capturedRelayPolicy = relayPolicy; return respondSuccessMock(res, { value: [] }); - } + }, + hashAPIPath: (input) => + input && input.startsWith("/api/endpoint/getportallogin_api") + ? "&hash=expected" + : "&hash=relay", + azureHeadersPaged: () => ({}) }); - const { res } = await invokePortalLogin(fixture); + + const req = { + query: { emailAddress: "user@test.local", hash: "expected" } + }; + const res = createRes(); + await mod.default(req, res); assert.strictEqual(res.state.statusCode, 200); assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { @@ -503,65 +423,30 @@ test("getportallogin passes strict relayPolicy overrides to relayGet", async () }); test("getportallogin accepts encoded email hash variant", async () => { - const fixture = createPortalLoginFixture({ - session: { user: { email: "user+test@local" } }, + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, relayGet: async ({ res }) => respondSuccessMock(res, { value: [{ contactid: "c1" }] }), hashAPIPath: (input) => input && input.includes("emailAddress=user%2Btest%40local") ? "&hash=expected" - : "&hash=other" - }); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "user+test@local", - hash: "expected" + : "&hash=other", + azureHeadersPaged: () => ({}) }); + const req = { + query: { emailAddress: "user+test@local", hash: "expected" } + }; + const res = createRes(); + await mod.default(req, res); + assert.strictEqual(res.state.statusCode, 200); assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { value: [{ contactid: "c1" }] }); }); -test("getportallogin uses normalized session email and preserves CRM response", async () => { - let capturedQueryUrl; - const crmResponse = { - value: [ - { - pinswg_preferredlanguage: 846040000, - emailaddress1: "user.o'neill@test.local", - contactid: "contact-1", - yomifullname: "O'Neill, User", - firstname: "User", - lastname: "O'Neill" - } - ] - }; - const fixture = createPortalLoginFixture({ - session: { user: { email: " User.O'Neill@Test.Local " } }, - relayGet: async ({ queryUrl, res }) => { - capturedQueryUrl = queryUrl; - return respondSuccessMock(res, crmResponse); - } - }); - const { res } = await invokePortalLogin(fixture, { - emailAddress: "user.o'neill@test.local", - hash: "expected" - }); - - assert.strictEqual(res.state.statusCode, 200); - assert.deepStrictEqual( - JSON.parse(JSON.stringify(res.state.jsonBody)), - crmResponse - ); - assert.match(capturedQueryUrl, /emailaddress1 eq 'user\.o''neill@test\.local'/); - assert.match(capturedQueryUrl, /statuscode eq 1/); - assert.match( - capturedQueryUrl, - /\$select=pinswg_preferredlanguage,emailaddress1,contactid,yomifullname,firstname,lastname$/ - ); -}); - test("getnewappeals catch path returns ADMIN_NEW_APPEALS_FETCH_FAILED", async () => { const mod = loadModule("pages/api/admin/getnewappeals_api.js", { respondError: respondErrorMock, diff --git a/tests/phase22/auth-redirect-safety.test.cjs b/tests/phase22/auth-redirect-safety.test.cjs index 0a194b5c..4416c311 100644 --- a/tests/phase22/auth-redirect-safety.test.cjs +++ b/tests/phase22/auth-redirect-safety.test.cjs @@ -6,10 +6,7 @@ const vm = require("vm"); const tests = []; const test = (name, fn) => tests.push({ name, fn }); -const loadAuthInternals = ({ - adapterFactory = () => ({}), - resolveCrmLocale = async () => null -} = {}) => { +const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => { const filePath = path.join( __dirname, "..", @@ -59,7 +56,7 @@ const loadAuthInternals = ({ }, NextAuth: () => ({}), EmailProvider: () => ({}), - resolveCrmLocale, + getPortalLogin: async () => ({ value: [] }), redactSensitive: (value) => value, consoleLogger: () => {}, console: { diff --git a/tests/phase22/preauth-locale-resolution.test.cjs b/tests/phase22/preauth-locale-resolution.test.cjs deleted file mode 100644 index 2e2d5b76..00000000 --- a/tests/phase22/preauth-locale-resolution.test.cjs +++ /dev/null @@ -1,242 +0,0 @@ -const assert = require("assert"); -const fs = require("fs"); -const path = require("path"); -const vm = require("vm"); - -const rootDir = path.resolve(__dirname, "..", ".."); -const tests = []; -const test = (name, fn) => tests.push({ name, fn }); - -const loadModule = (relativePath, injected = {}) => { - const filePath = path.join(rootDir, relativePath); - let source = fs.readFileSync(filePath, "utf8"); - const exportNames = Array.from( - source.matchAll(/export const\s+(\w+)\s*=/g) - ).map((match) => match[1]); - - source = source.replace(/import[\s\S]*?from\s+"[^"]+";\n?/g, ""); - source = source.replace(/export const\s+/g, "const "); - source = source.replace( - /export default async function\s+(\w+)\s*\(/g, - "async function $1(" - ); - source += `\nmodule.exports = { ${exportNames.join(", ")} };\n`; - source += - '\nif (typeof handler !== "undefined") module.exports.default = handler;\n'; - - const context = { - module: { exports: {} }, - exports: {}, - require, - process, - console: { log: () => {}, info: () => {}, warn: () => {}, error: () => {} }, - ...injected - }; - - vm.runInNewContext(source, context, { filename: filePath }); - return context.module.exports; -}; - -const normalize = (value) => JSON.parse(JSON.stringify(value)); - -const createRes = () => { - const state = { statusCode: null, jsonBody: undefined }; - - return { - state, - status(code) { - state.statusCode = code; - return this; - }, - json(body) { - state.jsonBody = body; - return body; - } - }; -}; - -const loadLocaleResolver = ({ relayGetData }) => - loadModule("lib/auth/resolveCrmLocale.js", { - azureHeadersPaged: (accessToken) => ({ accessToken }), - escapeODataString: (value) => value.trim().replace(/'/g, "''"), - consoleLogger: () => {}, - relayGetData, - RELAY_POLICY_STRICT_LOGIN: { - method: "GET", - timeoutMs: 5000, - maxRetries: 0, - retryBaseDelayMs: 0, - retryMaxDelayMs: 0 - } - }); - -test("pre-auth CRM locale lookup returns Welsh and selects only language", async () => { - let capturedRequest; - const mod = loadLocaleResolver({ - relayGetData: async (request) => { - capturedRequest = request; - return { - data: { - value: [ - { - pinswg_preferredlanguage: 846040000, - contactid: "must-not-be-returned", - firstname: "Must not be returned" - } - ] - } - }; - } - }); - - const result = await mod.resolveCrmLocale(" Person@Example.com "); - const selectClause = new URLSearchParams( - capturedRequest.queryUrl.split("?")[1] - ).get("$select"); - - assert.strictEqual(result, "cy"); - assert.match(capturedRequest.queryUrl, /\$filter=emailaddress1 eq /); - assert.match(capturedRequest.queryUrl, /statuscode eq 1/); - assert.strictEqual(selectClause, "pinswg_preferredlanguage"); - assert.ok(result === "en" || result === "cy" || result === null); - for (const field of [ - "contactid", - "emailaddress1", - "firstname", - "lastname", - "yomifullname" - ]) { - assert.doesNotMatch(selectClause, new RegExp(field)); - assert.doesNotMatch(result, new RegExp(field)); - } -}); - -test("pre-auth CRM locale lookup returns English for a non-Welsh value", async () => { - const mod = loadLocaleResolver({ - relayGetData: async () => ({ - data: { value: [{ pinswg_preferredlanguage: 846040001 }] } - }) - }); - - assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en"); -}); - -test("pre-auth CRM locale lookup returns null for no contact", async () => { - const mod = loadLocaleResolver({ - relayGetData: async () => ({ data: { value: [] } }) - }); - - assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null); -}); - -test("pre-auth CRM locale lookup preserves first-contact duplicate semantics", async () => { - const mod = loadLocaleResolver({ - relayGetData: async () => ({ - data: { - value: [ - { pinswg_preferredlanguage: 846040001 }, - { pinswg_preferredlanguage: 846040000 } - ] - } - }) - }); - - assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), "en"); -}); - -test("pre-auth CRM locale lookup rejects malformed email without CRM access", async () => { - let relayCalls = 0; - const mod = loadLocaleResolver({ - relayGetData: async () => { - relayCalls += 1; - return { data: { value: [] } }; - } - }); - - assert.strictEqual(await mod.resolveCrmLocale("not-an-email"), null); - assert.strictEqual(relayCalls, 0); -}); - -test("pre-auth CRM locale lookup fails closed without leaking CRM details", async () => { - const mod = loadLocaleResolver({ - relayGetData: async () => { - throw new Error("CRM secret response details"); - } - }); - - assert.strictEqual(await mod.resolveCrmLocale("person@example.com"), null); -}); - -test("pre-auth CRM locale lookup escapes OData apostrophes", async () => { - let queryUrl; - const mod = loadLocaleResolver({ - relayGetData: async (request) => { - queryUrl = request.queryUrl; - return { data: { value: [] } }; - } - }); - - await mod.resolveCrmLocale("o'connor@example.com"); - - assert.match(queryUrl, /o''connor@example\.com/); -}); - -test("public sign-in locale route preserves response without CRM lookup", async () => { - const source = fs.readFileSync( - path.join(rootDir, "pages/api/auth/resolve-locale.js"), - "utf8" - ); - const mod = loadModule("pages/api/auth/resolve-locale.js"); - const res = createRes(); - - await mod.default( - { - method: "POST", - body: { email: "other@example.com", locale: "cy" }, - query: {}, - cookies: {} - }, - res - ); - - assert.strictEqual(res.state.statusCode, 200); - assert.deepStrictEqual(normalize(res.state.jsonBody), { locale: "cy" }); - assert.doesNotMatch(source, /getPortalLogin|resolveCrmLocale|relayGetData/); -}); - -test("both pre-auth callers no longer depend on getPortalLogin", () => { - const nextAuthSource = fs.readFileSync( - path.join(rootDir, "pages/api/auth/[...nextauth].js"), - "utf8" - ); - const routeSource = fs.readFileSync( - path.join(rootDir, "pages/api/auth/resolve-locale.js"), - "utf8" - ); - - assert.doesNotMatch(nextAuthSource, /getPortalLogin/); - assert.doesNotMatch(routeSource, /getPortalLogin/); - assert.match(nextAuthSource, /resolveCrmLocale/); -}); - -const run = async () => { - let passed = 0; - - for (const currentTest of tests) { - await currentTest.fn(); - passed += 1; - } - - console.log( - `Phase 22 pre-auth locale tests passed (${passed}/${tests.length}).` - ); -}; - -if (require.main === module) { - run().catch((error) => { - console.error(error); - process.exit(1); - }); -} - -module.exports = run; \ No newline at end of file