From e13700ab654fb259195d6bc334c603c5ecd9c423 Mon Sep 17 00:00:00 2001 From: robbond Date: Thu, 5 Nov 2026 06:49:00 +0000 Subject: [PATCH] security: enforce authenticated CRM contact ownership --- 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, 517 insertions(+), 126 deletions(-) create mode 100644 lib/auth/resolveCrmLocale.js create mode 100644 tests/phase22/preauth-locale-resolution.test.cjs diff --git a/lib/auth/resolveCrmLocale.js b/lib/auth/resolveCrmLocale.js new file mode 100644 index 00000000..0677ab16 --- /dev/null +++ b/lib/auth/resolveCrmLocale.js @@ -0,0 +1,42 @@ +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 ac88da54..f953bf13 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,8 +53,6 @@ const createVerificationTokenSynchronizationAdapter = ( }; }; -const WELSH_LANGUAGE_CODE = 846040000; - const authDiagnostic = (event, metadata = {}) => { try { const safeMeta = JSON.parse(JSON.stringify(metadata)); @@ -84,29 +82,6 @@ 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 f89bf9df..f029dce2 100644 --- a/pages/api/auth/resolve-locale.js +++ b/pages/api/auth/resolve-locale.js @@ -1,8 +1,3 @@ -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 || @@ -14,30 +9,8 @@ export default async function handler(req, res) { return res.status(405).json({ message: "Method not allowed" }); } - const email = String(req.body?.email || "") - .trim() - .toLowerCase(); - const sessionLocale = resolveRequestLocale(req); + const requestedLocale = resolveRequestLocale(req); + const locale = requestedLocale === "cy" ? "cy" : "en"; - 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 }); - } + return res.status(200).json({ locale }); } diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index 00871021..c690be08 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -17,20 +17,61 @@ */ 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" || emailAddress.trim().length === 0) { + if ( + typeof emailAddress !== "string" || + !EMAIL_PATTERN.test(emailAddress.trim()) + ) { return respondError(res, { status: 400, - code: "EMAIL_ADDRESS_REQUIRED", - message: "emailAddress is required" + 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" }); } @@ -63,7 +104,7 @@ export default async function ApiProxy(req, res) { const queryUrl = "contacts?$filter=emailaddress1 eq '" + - emailAddress + + escapeODataString(normalizedAuthenticatedEmail) + "' 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 6c3b2f0f..3821f9e2 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -323,64 +323,157 @@ test("getlogin passes strict relayPolicy overrides to relayGet", async () => { }); }); -test("getportallogin returns HASH_REQUIRED when hash missing", async () => { +const createPortalLoginFixture = (overrides = {}) => { + const calls = { + session: [], + hash: [], + relay: [] + }; + const session = Object.prototype.hasOwnProperty.call(overrides, "session") + ? overrides.session + : { user: { email: "user@test.local" } }; + 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: () => ({}) + 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 }) }); - const req = { query: { emailAddress: "user@test.local" } }; + return { mod, calls }; +}; + +const invokePortalLogin = async ( + fixture, + query = { emailAddress: "user@test.local", hash: "expected" }, + method = "GET" +) => { + const req = { method, query }; const res = createRes(); - await mod.default(req, res); + 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({ + 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" + }); + + 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" + }); 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 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 fixture = createPortalLoginFixture(); + const { res } = await invokePortalLogin(fixture, { + emailAddress: "user@test.local", + hash: "wrong" }); - 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 mod = loadModule("pages/api/endpoint/getportallogin_api.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, + const fixture = createPortalLoginFixture({ relayGet: async ({ res, errorResponse }) => - respondErrorMock(res, errorResponse), - hashAPIPath: (input) => - input && input.startsWith("/api/endpoint/getportallogin_api") - ? "&hash=expected" - : "&hash=relay", - azureHeadersPaged: () => ({}) + respondErrorMock(res, errorResponse) }); - - const req = { - query: { emailAddress: "user@test.local", hash: "expected" } - }; - const res = createRes(); - await mod.default(req, res); + const { res } = await invokePortalLogin(fixture); assert.strictEqual(res.state.statusCode, 400); assert.strictEqual( @@ -391,26 +484,13 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => test("getportallogin passes strict relayPolicy overrides to relayGet", async () => { let capturedRelayPolicy = null; - - const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, + const fixture = createPortalLoginFixture({ 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 req = { - query: { emailAddress: "user@test.local", hash: "expected" } - }; - const res = createRes(); - await mod.default(req, res); + const { res } = await invokePortalLogin(fixture); assert.strictEqual(res.state.statusCode, 200); assert.deepStrictEqual(JSON.parse(JSON.stringify(capturedRelayPolicy)), { @@ -423,23 +503,19 @@ test("getportallogin passes strict relayPolicy overrides to relayGet", async () }); test("getportallogin accepts encoded email hash variant", async () => { - const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { - respondError: respondErrorMock, - respondSuccess: respondSuccessMock, + const fixture = createPortalLoginFixture({ + session: { user: { email: "user+test@local" } }, relayGet: async ({ res }) => respondSuccessMock(res, { value: [{ contactid: "c1" }] }), hashAPIPath: (input) => input && input.includes("emailAddress=user%2Btest%40local") ? "&hash=expected" - : "&hash=other", - azureHeadersPaged: () => ({}) + : "&hash=other" + }); + const { res } = await invokePortalLogin(fixture, { + emailAddress: "user+test@local", + hash: "expected" }); - - 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)), { @@ -447,6 +523,45 @@ test("getportallogin accepts encoded email hash variant", async () => { }); }); +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 4416c311..0a194b5c 100644 --- a/tests/phase22/auth-redirect-safety.test.cjs +++ b/tests/phase22/auth-redirect-safety.test.cjs @@ -6,7 +6,10 @@ const vm = require("vm"); const tests = []; const test = (name, fn) => tests.push({ name, fn }); -const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => { +const loadAuthInternals = ({ + adapterFactory = () => ({}), + resolveCrmLocale = async () => null +} = {}) => { const filePath = path.join( __dirname, "..", @@ -56,7 +59,7 @@ const loadAuthInternals = ({ adapterFactory = () => ({}) } = {}) => { }, NextAuth: () => ({}), EmailProvider: () => ({}), - getPortalLogin: async () => ({ value: [] }), + resolveCrmLocale, redactSensitive: (value) => value, consoleLogger: () => {}, console: { diff --git a/tests/phase22/preauth-locale-resolution.test.cjs b/tests/phase22/preauth-locale-resolution.test.cjs new file mode 100644 index 00000000..2e2d5b76 --- /dev/null +++ b/tests/phase22/preauth-locale-resolution.test.cjs @@ -0,0 +1,242 @@ +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