From d18a7d22d3adef8537663f34944edb5b32de4432 Mon Sep 17 00:00:00 2001 From: Stef Lewandowski Date: Mon, 14 Oct 2024 11:56:20 +0100 Subject: [PATCH 1/9] fix: reduce middleware syntax error logs, send to logs, tests for CSP in each env (#213) Co-authored-by: Adam Howard <91115+codeincontext@users.noreply.github.com> --- .vscode/settings.json | 2 + .../src/lib/errors/getRootErrorCause.ts | 9 + apps/nextjs/src/middleware.test.ts | 273 ++++++++++++++++++ apps/nextjs/src/middleware.ts | 52 +++- .../__snapshots__/csp.test.ts.snap | 54 ++++ .../nextjs/src/middlewares/auth.middleware.ts | 7 +- apps/nextjs/src/middlewares/csp.test.ts | 93 ++++++ apps/nextjs/src/middlewares/csp.ts | 229 +++++++-------- .../src/middlewares/middlewareErrorLogging.ts | 59 ++++ 9 files changed, 650 insertions(+), 128 deletions(-) create mode 100644 apps/nextjs/src/lib/errors/getRootErrorCause.ts create mode 100644 apps/nextjs/src/middleware.test.ts create mode 100644 apps/nextjs/src/middlewares/__snapshots__/csp.test.ts.snap create mode 100644 apps/nextjs/src/middlewares/csp.test.ts create mode 100644 apps/nextjs/src/middlewares/middlewareErrorLogging.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 537f9ffef..bee03c4b1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -48,6 +48,7 @@ "gdrive", "Geist", "gleap", + "gstatic", "Hardman", "haroset", "Hasura", @@ -109,6 +110,7 @@ "PSED", "PSHE", "psql", + "pusherapp", "ratelimit", "Regen", "remeda", diff --git a/apps/nextjs/src/lib/errors/getRootErrorCause.ts b/apps/nextjs/src/lib/errors/getRootErrorCause.ts new file mode 100644 index 000000000..55a95cf9f --- /dev/null +++ b/apps/nextjs/src/lib/errors/getRootErrorCause.ts @@ -0,0 +1,9 @@ +export function getRootErrorCause(error: unknown) { + if (!(error instanceof Error)) { + return error; + } + if ("cause" in error && error.cause) { + return getRootErrorCause(error.cause); + } + return error; +} diff --git a/apps/nextjs/src/middleware.test.ts b/apps/nextjs/src/middleware.test.ts new file mode 100644 index 000000000..a60f94b39 --- /dev/null +++ b/apps/nextjs/src/middleware.test.ts @@ -0,0 +1,273 @@ +import { NextRequest, NextFetchEvent, NextResponse } from "next/server"; + +import { handleError } from "./middleware"; +import { CspConfig, addCspHeaders, buildCspHeaders } from "./middlewares/csp"; + +jest.mock("./middlewares/csp", () => { + const originalModule = jest.requireActual("./middlewares/csp"); + return { + ...originalModule, + generateNonce: jest.fn(() => "mocked-nonce"), + }; +}); + +describe("handleError", () => { + let mockRequest: NextRequest; + let mockEvent: NextFetchEvent; + + beforeEach(() => { + mockRequest = { + url: "https://example.com", + method: "GET", + headers: new Headers(), + cookies: new Map(), + geo: {}, + ip: "127.0.0.1", + nextUrl: { pathname: "/", search: "" }, + clone: jest.fn().mockReturnThis(), + text: jest.fn().mockResolvedValue(""), + } as unknown as NextRequest; + + mockEvent = { + sourcePage: "/test", + } as NextFetchEvent; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("handles SyntaxError correctly", async () => { + const error = new SyntaxError("Test syntax error"); + const response = await handleError(error, mockRequest, mockEvent); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Bad Request" }); + }); + + it("handles wrapped SyntaxError correctly", async () => { + const error = new Error("Wrapper error"); + error.cause = new SyntaxError("Test syntax error"); + const response = await handleError(error, mockRequest, mockEvent); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Bad Request" }); + }); + + it("handles other errors correctly", async () => { + const error = new Error("Test error"); + const response = await handleError(error, mockRequest, mockEvent); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: "Internal Server Error" }); + }); +}); + +describe("addCspHeaders", () => { + let mockRequest: NextRequest; + let mockResponse: NextResponse; + let defaultConfig: CspConfig; + + beforeEach(() => { + mockRequest = new NextRequest("https://example.com", { + method: "GET", + headers: new Headers(), + }); + + mockResponse = new NextResponse(); + + defaultConfig = { + strictCsp: true, + environment: "production", + sentryEnv: "test", + sentryRelease: "1.0.0", + sentryReportUri: "https://sentry.io/report", + cspReportSampleRate: "1", + vercelEnv: "production", + enabledPolicies: { + clerk: false, + avo: false, + posthog: false, + devConsent: false, + mux: true, + vercel: false, + }, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("adds CSP headers to the response", () => { + const result = addCspHeaders(mockResponse, mockRequest, defaultConfig); + + expect(result.headers.has("Content-Security-Policy")).toBe(true); + expect(result.headers.get("x-middleware-csp-nonce")).toBeTruthy(); + }); + + it("does not add CSP headers for _next/static paths", () => { + mockRequest = new NextRequest("https://example.com/_next/static/chunk.js", { + method: "GET", + headers: new Headers(), + }); + + const result = addCspHeaders(mockResponse, mockRequest, defaultConfig); + + expect(result.headers.has("Content-Security-Policy")).toBe(false); + expect(result.headers.has("x-middleware-csp-nonce")).toBe(false); + }); + + it("does not add CSP headers for next-router-prefetch requests", () => { + mockRequest = new NextRequest("https://example.com", { + method: "GET", + headers: new Headers({ "next-router-prefetch": "1" }), + }); + + const result = addCspHeaders(mockResponse, mockRequest, defaultConfig); + + expect(result.headers.has("Content-Security-Policy")).toBe(false); + expect(result.headers.has("x-middleware-csp-nonce")).toBe(false); + }); + + it("adds CSP-Report-Only header when strictCsp is false", () => { + const config = { ...defaultConfig, strictCsp: false }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + expect(result.headers.has("Content-Security-Policy-Report-Only")).toBe( + true, + ); + }); + + it("does not add CSP-Report-Only header when strictCsp is true", () => { + const result = addCspHeaders(mockResponse, mockRequest, defaultConfig); + + expect(result.headers.has("Content-Security-Policy-Report-Only")).toBe( + false, + ); + }); + + it("includes Clerk policies when enabled", () => { + const config = { + ...defaultConfig, + enabledPolicies: { ...defaultConfig.enabledPolicies, clerk: true }, + }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + const cspHeader = result.headers.get("Content-Security-Policy"); + expect(cspHeader).toContain("*.clerk.accounts.dev"); + }); + + it("includes development-specific directives when environment is development", () => { + const config = { ...defaultConfig, environment: "development" }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + const cspHeader = result.headers.get("Content-Security-Policy"); + expect(cspHeader).toContain("'unsafe-eval'"); + expect(cspHeader).not.toContain("upgrade-insecure-requests"); + }); + + it("includes all Mux policies when enabled", () => { + const config = { + ...defaultConfig, + enabledPolicies: { ...defaultConfig.enabledPolicies, mux: true }, + }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + const cspHeader = result.headers.get("Content-Security-Policy"); + expect(cspHeader).toContain("https://cdn.mux.com"); + expect(cspHeader).toContain("https://stream.mux.com"); + expect(cspHeader).toContain("https://inferred.litix.io"); + }); + + it("includes all required policies", () => { + const result = addCspHeaders(mockResponse, mockRequest, defaultConfig); + const cspHeader = result.headers.get("Content-Security-Policy"); + + // Check for base policies + expect(cspHeader).toContain("default-src 'self'"); + expect(cspHeader).toContain("script-src 'self'"); + expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'"); + + // Check for specific domains + expect(cspHeader).toContain("*.thenational.academy"); + expect(cspHeader).toContain("*.hubspot.com"); + expect(cspHeader).toContain("https://img.clerk.com"); + expect(cspHeader).toContain("https://res.cloudinary.com"); + + // Check for nonce + expect(cspHeader).toMatch(/'nonce-[a-zA-Z0-9+/=]{24}'/); + }); + + it("includes Clerk policies when enabled", () => { + const config = { + ...defaultConfig, + enabledPolicies: { ...defaultConfig.enabledPolicies, clerk: true }, + }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + const cspHeader = result.headers.get("Content-Security-Policy"); + expect(cspHeader).toContain("*.clerk.accounts.dev"); + }); + + it("includes Vercel policies when enabled", () => { + const config = { + ...defaultConfig, + enabledPolicies: { ...defaultConfig.enabledPolicies, vercel: true }, + }; + const result = addCspHeaders(mockResponse, mockRequest, config); + + const cspHeader = result.headers.get("Content-Security-Policy"); + expect(cspHeader).toContain("https://vercel.live/"); + expect(cspHeader).toContain("https://vercel.com"); + expect(cspHeader).toContain("*.pusher.com"); + }); +}); + +describe("buildCspHeaders", () => { + const mockNonce = "test-nonce"; + const mockConfig: CspConfig = { + strictCsp: true, + environment: "production", + sentryEnv: "test", + sentryRelease: "1.0.0", + sentryReportUri: "https://sentry.io/report", + cspReportSampleRate: "1", + vercelEnv: "production", + enabledPolicies: { + clerk: false, + avo: false, + posthog: false, + devConsent: false, + mux: true, + vercel: false, + }, + }; + + it("generates correct CSP headers", () => { + const result = buildCspHeaders(mockNonce, mockConfig); + + expect(result.policy).toContain(`'nonce-${mockNonce}'`); + expect(result.policy).toContain("upgrade-insecure-requests"); + + expect(result.policy).toContain("default-src 'self'"); + expect(result.policy).toContain("script-src 'self'"); + expect(result.policy).toContain("style-src 'self' 'unsafe-inline'"); + + if (mockConfig.enabledPolicies.mux) { + expect(result.policy).toContain("https://cdn.mux.com"); + } + if (mockConfig.enabledPolicies.clerk) { + expect(result.policy).toContain("*.clerk.accounts.dev"); + } + }); + + it("generates report-only CSP when strictCsp is false", () => { + const nonStrictConfig = { ...mockConfig, strictCsp: false }; + const result = buildCspHeaders(mockNonce, nonStrictConfig); + + expect(result.policy).toContain("frame-ancestors 'self'"); + expect(result.reportOnly).toBeDefined(); + expect(result.reportOnly).toContain("default-src 'self'"); + }); +}); diff --git a/apps/nextjs/src/middleware.ts b/apps/nextjs/src/middleware.ts index 5444ceb01..bd95d06fa 100644 --- a/apps/nextjs/src/middleware.ts +++ b/apps/nextjs/src/middleware.ts @@ -1,20 +1,48 @@ -import * as Sentry from "@sentry/nextjs"; -import { authMiddleware } from "middlewares/auth.middleware"; -import { addCspHeaders } from "middlewares/csp"; import { NextMiddlewareResult } from "next/dist/server/web/types"; -import { NextMiddleware, NextResponse } from "next/server"; +import { + NextFetchEvent, + NextMiddleware, + NextRequest, + NextResponse, +} from "next/server"; +import { getRootErrorCause } from "./lib/errors/getRootErrorCause"; import { sentryCleanup } from "./lib/sentry/sentryCleanup"; +import { authMiddleware } from "./middlewares/auth.middleware"; +import { CspConfig, addCspHeaders } from "./middlewares/csp"; +import { logError } from "./middlewares/middlewareErrorLogging"; -function handleError(error: unknown, extra: Record): Response { - if (error instanceof SyntaxError) { - console.error("Handled SyntaxError in nextMiddleware", error); +const cspConfig: CspConfig = { + strictCsp: process.env.STRICT_CSP === "true", + environment: process.env.NEXT_PUBLIC_ENVIRONMENT || "", + sentryEnv: process.env.NEXT_PUBLIC_SENTRY_ENV || "", + sentryRelease: process.env.NEXT_PUBLIC_APP_VERSION || "", + sentryReportUri: process.env.SENTRY_REPORT_URI || "", + cspReportSampleRate: process.env.NEXT_PUBLIC_CSP_REPORT_SAMPLE_RATE || "1", + vercelEnv: process.env.VERCEL_ENV || "", + enabledPolicies: { + clerk: ["dev", "stg"].includes(process.env.NEXT_PUBLIC_ENVIRONMENT || ""), + avo: ["dev", "stg"].includes(process.env.NEXT_PUBLIC_ENVIRONMENT || ""), + posthog: process.env.NEXT_PUBLIC_ENVIRONMENT === "dev", + devConsent: process.env.NEXT_PUBLIC_ENVIRONMENT === "dev", + mux: true, + vercel: process.env.VERCEL_ENV === "preview", + }, +}; + +export async function handleError( + error: unknown, + request: NextRequest, + event: NextFetchEvent, +): Promise { + const rootError = getRootErrorCause(error); + + await logError(rootError, request, event); + + if (rootError instanceof SyntaxError) { return NextResponse.json({ error: "Bad Request" }, { status: 400 }); } - const wrappedError = new Error("Error in nextMiddleware", { cause: error }); - Sentry.captureException(wrappedError, { extra }); - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); } @@ -23,9 +51,9 @@ const nextMiddleware: NextMiddleware = async (request, event) => { try { response = await authMiddleware(request, event); - response = addCspHeaders(response, request); + response = addCspHeaders(response, request, cspConfig); } catch (error) { - response = handleError(error, { request, event }); + response = await handleError(error, request, event); } finally { await sentryCleanup(); } diff --git a/apps/nextjs/src/middlewares/__snapshots__/csp.test.ts.snap b/apps/nextjs/src/middlewares/__snapshots__/csp.test.ts.snap new file mode 100644 index 000000000..f94f7ab1d --- /dev/null +++ b/apps/nextjs/src/middlewares/__snapshots__/csp.test.ts.snap @@ -0,0 +1,54 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSP Policies Snapshot should match development environment snapshot 1`] = ` +"default-src 'self' +media-src 'self' 'self' https://*.mux.com https://stream.mux.com blob: +script-src 'self' 'nonce-AAAAAAAAAAAAAAAAAAAAAA==' 'strict-dynamic' https: http: 'unsafe-inline' 'unsafe-eval' https://cdn.mux.com https://mux.com https://*.mux.com https://stream.mux.com +style-src 'self' 'unsafe-inline' https://*.mux.com +connect-src 'self' *.thenational.academy *.hubspot.com *.clerk.accounts.dev https://api.avo.app/ https://eu.i.posthog.com https://europe-west2-oak-ai-beta-staging.cloudfunctions.net https://mux.com https://*.mux.com https://stream.mux.com https://inferred.litix.io +worker-src 'self' blob: +img-src 'self' blob: data: https://img.clerk.com https://res.cloudinary.com https://*.hubspot.com https://*.hsforms.com https://*.mux.com https://stream.mux.com +font-src 'self' gstatic-fonts.thenational.academy fonts.gstatic.com +object-src 'none' +base-uri 'self' +frame-src 'self' *.thenational.academy https://challenges.cloudflare.com https://*.mux.com https://www.avo.app/ https://stream.mux.com +form-action 'self' +frame-ancestors 'none' +report-uri https://sentry.io/report&sentry_environment=test&sentry_release=1.0.0" +`; + +exports[`CSP Policies Snapshot should match preview environment snapshot 1`] = ` +"default-src 'self' +media-src 'self' 'self' https://*.mux.com https://stream.mux.com blob: +script-src 'self' 'nonce-AAAAAAAAAAAAAAAAAAAAAA==' 'strict-dynamic' https: http: 'unsafe-inline' 'unsafe-eval' https://vercel.live https://vercel.com https://cdn.mux.com https://mux.com https://*.mux.com https://stream.mux.com +style-src 'self' 'unsafe-inline' https://vercel.live/ https://*.mux.com +connect-src 'self' *.thenational.academy *.hubspot.com https://vercel.live/ https://vercel.com *.pusher.com *.pusherapp.com *.clerk.accounts.dev https://api.avo.app/ https://mux.com https://*.mux.com https://stream.mux.com https://inferred.litix.io +worker-src 'self' blob: +img-src 'self' blob: data: https://img.clerk.com https://res.cloudinary.com https://*.hubspot.com https://*.hsforms.com https://vercel.live/ https://vercel.com *.pusher.com/ data: blob: https://*.mux.com https://stream.mux.com +font-src 'self' gstatic-fonts.thenational.academy fonts.gstatic.com https://vercel.live/ https://assets.vercel.com +object-src 'none' +base-uri 'self' +frame-src 'self' *.thenational.academy https://challenges.cloudflare.com https://*.mux.com https://vercel.live/ https://vercel.com https://www.avo.app/ https://stream.mux.com +form-action 'self' +frame-ancestors 'none' +report-uri https://sentry.io/report&sentry_environment=test&sentry_release=1.0.0 +upgrade-insecure-requests" +`; + +exports[`CSP Policies Snapshot should match production environment snapshot 1`] = ` +"default-src 'self' +media-src 'self' 'self' https://*.mux.com https://stream.mux.com blob: +script-src 'self' 'nonce-AAAAAAAAAAAAAAAAAAAAAA==' 'strict-dynamic' https: http: 'unsafe-inline' https://cdn.mux.com https://mux.com https://*.mux.com https://stream.mux.com +style-src 'self' 'unsafe-inline' https://*.mux.com +connect-src 'self' *.thenational.academy *.hubspot.com https://mux.com https://*.mux.com https://stream.mux.com https://inferred.litix.io +worker-src 'self' blob: +img-src 'self' blob: data: https://img.clerk.com https://res.cloudinary.com https://*.hubspot.com https://*.hsforms.com https://*.mux.com https://stream.mux.com +font-src 'self' gstatic-fonts.thenational.academy fonts.gstatic.com +object-src 'none' +base-uri 'self' +frame-src 'self' *.thenational.academy https://challenges.cloudflare.com https://*.mux.com https://stream.mux.com +form-action 'self' +frame-ancestors 'none' +report-uri https://sentry.io/report&sentry_environment=test&sentry_release=1.0.0 +upgrade-insecure-requests" +`; diff --git a/apps/nextjs/src/middlewares/auth.middleware.ts b/apps/nextjs/src/middlewares/auth.middleware.ts index a8ef3f033..9251fc8b6 100644 --- a/apps/nextjs/src/middlewares/auth.middleware.ts +++ b/apps/nextjs/src/middlewares/auth.middleware.ts @@ -122,7 +122,10 @@ function conditionallyProtectRoute( return; } - if (process.env.NODE_ENV === "development" && req.headers["x-dev-preload"]) { + if ( + process.env.NODE_ENV === "development" && + req.headers.get("x-dev-preload") + ) { if (isPreloadableRoute(req)) { log("Dev preload route: ALLOW"); return; @@ -149,7 +152,7 @@ export async function authMiddleware( return response; } } catch (error) { - console.error("Error in authMiddleware", error); + console.error({ event: "middleware.auth.error", error }); throw new Error("Error in authMiddleware", { cause: error }); } diff --git a/apps/nextjs/src/middlewares/csp.test.ts b/apps/nextjs/src/middlewares/csp.test.ts new file mode 100644 index 000000000..a6afe104c --- /dev/null +++ b/apps/nextjs/src/middlewares/csp.test.ts @@ -0,0 +1,93 @@ +import { NextRequest } from "next/server"; + +import { addCspHeaders, CspConfig } from "./csp"; + +const mockedCrypto = { + randomBytes: jest.fn(() => ({ + toString: jest.fn(() => "mocked-nonce"), + })), +}; + +// Mock the global require function +const originalRequire = jest.requireActual("module"); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).require = Object.assign( + jest.fn((moduleName: string) => { + if (moduleName === "crypto") { + return mockedCrypto; + } + return originalRequire(moduleName); + }), + { + resolve: originalRequire.resolve, + cache: originalRequire.cache, + extensions: originalRequire.extensions, + main: originalRequire.main, + }, +); + +// Mock the global crypto object +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).crypto = { + getRandomValues: jest.fn((array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i % 256; // Fill with predictable values + } + return array; + }), + subtle: {}, + randomUUID: jest.fn(() => "mocked-uuid"), +}; + +const environments = ["development", "production", "preview"] as const; +type Environment = (typeof environments)[number]; + +function generatePoliciesForEnvironment(env: Environment): string { + const mockRequest = new NextRequest("https://example.com"); + const mockResponse = new Response(); + + const config: CspConfig = { + strictCsp: true, + environment: env, + sentryEnv: "test", + sentryRelease: "1.0.0", + sentryReportUri: "https://sentry.io/report", + cspReportSampleRate: "1", + vercelEnv: env === "preview" ? "preview" : "production", + enabledPolicies: { + clerk: env !== "production", + avo: env !== "production", + posthog: env === "development", + devConsent: env === "development", + mux: true, + vercel: env === "preview", + }, + }; + + const result = addCspHeaders(mockResponse, mockRequest, config); + const cspHeader = result.headers.get("Content-Security-Policy") || ""; + return cspHeader + .split(";") + .map((policy) => policy.trim()) + .join("\n"); +} + +describe("CSP Policies Snapshot", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + // Restore the original require function + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).require = originalRequire; + }); + + environments.forEach((env) => { + it(`should match ${env} environment snapshot`, () => { + const generatedPolicies = generatePoliciesForEnvironment(env); + expect(generatedPolicies).toMatchSnapshot(); + }); + }); +}); diff --git a/apps/nextjs/src/middlewares/csp.ts b/apps/nextjs/src/middlewares/csp.ts index 6dc7d10ed..477161660 100644 --- a/apps/nextjs/src/middlewares/csp.ts +++ b/apps/nextjs/src/middlewares/csp.ts @@ -1,56 +1,70 @@ import { NextRequest, NextResponse } from "next/server"; -const sentryEnv = process.env.NEXT_PUBLIC_SENTRY_ENV; -const sentryRelease = process.env.NEXT_PUBLIC_APP_VERSION; -const sentryReportUri = `${process.env.SENTRY_REPORT_URI}&sentry_environment=${sentryEnv}&sentry_release=${sentryRelease}`; +function generateNonce(): string { + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return btoa(String.fromCharCode.apply(null, array as unknown as number[])); + } else if (typeof require !== "undefined") { + // We are running this in a test Node.js environment + // for testing purposes + // eslint-disable-next-line @typescript-eslint/no-var-requires + const crypto = require("crypto"); + return crypto.randomBytes(16).toString("base64"); + } else { + // Fallback for environments where neither is available + throw new Error( + "Unable to generate nonce: No secure random number generator available", + ); + } +} + +export interface CspConfig { + strictCsp: boolean; + environment: string; + sentryEnv: string; + sentryRelease: string; + sentryReportUri: string; + cspReportSampleRate: string; + vercelEnv: string; + enabledPolicies: { + clerk: boolean; + avo: boolean; + posthog: boolean; + devConsent: boolean; + mux: boolean; + vercel: boolean; + }; +} -const getReportUri = () => { - const rate = Number.parseFloat( - process.env.NEXT_PUBLIC_CSP_REPORT_SAMPLE_RATE || "1", - ); - if (sentryEnv === "production" && Math.random() > rate) { +const getReportUri = (config: CspConfig) => { + const rate = Number.parseFloat(config.cspReportSampleRate); + if (config.environment === "production" && Math.random() > rate) { return ""; } - return sentryReportUri; + return `${config.sentryReportUri}&sentry_environment=${config.sentryEnv}&sentry_release=${config.sentryRelease}`; }; -const clerkPolicies = - process.env.NEXT_PUBLIC_ENVIRONMENT === "dev" || - process.env.NEXT_PUBLIC_ENVIRONMENT === "stg" - ? { - "connect-src": ["*.clerk.accounts.dev"], - } - : {}; +const clerkPolicies: Record = { + "connect-src": ["*.clerk.accounts.dev"], +}; -const avoPolicies = - process.env.NEXT_PUBLIC_ENVIRONMENT === "dev" || - process.env.NEXT_PUBLIC_ENVIRONMENT === "stg" - ? { - // lets us use avo's debugger in dev - "frame-src": ["https://www.avo.app/"], - "connect-src": ["https://api.avo.app/"], - } - : {}; +const avoPolicies: Record = { + "frame-src": ["https://www.avo.app/"], + "connect-src": ["https://api.avo.app/"], +}; -const posthogPolicies = - process.env.NEXT_PUBLIC_ENVIRONMENT === "dev" - ? { - "connect-src": ["https://eu.i.posthog.com"], - } - : {}; +const posthogPolicies: Record = { + "connect-src": ["https://eu.i.posthog.com"], +}; -const devConsentPolicies = - process.env.NEXT_PUBLIC_ENVIRONMENT === "dev" - ? { - /* our consent deployment for dev doesn't live behind thenational.academy, - so we need to allow the specific cloud functions URL */ - "connect-src": [ - "https://europe-west2-oak-ai-beta-staging.cloudfunctions.net", - ], - } - : {}; +const devConsentPolicies: Record = { + "connect-src": [ + "https://europe-west2-oak-ai-beta-staging.cloudfunctions.net", + ], +}; -const mux = { +const mux: Record = { "script-src": [ "https://cdn.mux.com", "https://mux.com", @@ -74,38 +88,34 @@ const mux = { "frame-src": ["https://stream.mux.com"], }; -const vercelPolicies = - process.env.VERCEL_ENV === "preview" - ? { - "script-src": ["https://vercel.live/", "https://vercel.com"], - "connect-src": [ - "https://vercel.live/", - "https://vercel.com", - "*.pusher.com", - "*.pusherapp.com", - ], - "img-src": [ - "https://vercel.live/", - "https://vercel.com", - "*.pusher.com/", - "data:", - "blob:", - ], - "frame-src": ["https://vercel.live/", "https://vercel.com"], - "style-src": ["https://vercel.live/"], - "font-src": ["https://vercel.live/", "https://assets.vercel.com"], - } - : {}; +const vercelPolicies: Record = { + "script-src": ["https://vercel.live", "https://vercel.com"], + "connect-src": [ + "https://vercel.live/", + "https://vercel.com", + "*.pusher.com", + "*.pusherapp.com", + ], + "img-src": [ + "https://vercel.live/", + "https://vercel.com", + "*.pusher.com/", + "data:", + "blob:", + ], + "frame-src": ["https://vercel.live/", "https://vercel.com"], + "style-src": ["https://vercel.live/"], + "font-src": ["https://vercel.live/", "https://assets.vercel.com"], +}; -const addUpgradeInsecure = (csp: string) => { - // safari looks at upgrade-insecure-requests on localhost and will upgrade resources to https - if (process.env.NODE_ENV === "development") { +const addUpgradeInsecure = (csp: string, config: CspConfig) => { + if (config.environment === "development") { return csp; } return `${csp}; upgrade-insecure-requests`; }; -const buildCspHeaders = (nonce: string) => { +export const buildCspHeaders = (nonce: string, config: CspConfig) => { const legacyCspHeader = `frame-ancestors 'self';script-src-next-nonce 'nonce-${nonce}'`; const baseCsp = { @@ -117,8 +127,8 @@ const buildCspHeaders = (nonce: string) => { "'strict-dynamic'", "https:", "http:", - "'unsafe-inline'", // NOTE: unsafe-inline is ignored in browser that support nonce - process.env.NODE_ENV === "production" ? "" : "'unsafe-eval'", + "'unsafe-inline'", + config.environment === "production" ? "" : "'unsafe-eval'", ], "style-src": ["'self'", "'unsafe-inline'"], "connect-src": ["'self'", "*.thenational.academy", "*.hubspot.com"], @@ -134,9 +144,7 @@ const buildCspHeaders = (nonce: string) => { ], "font-src": [ "'self'", - // Oak font subdomain "gstatic-fonts.thenational.academy", - // Google fonts used by third party tools "fonts.gstatic.com", ], "object-src": ["'none'"], @@ -149,42 +157,40 @@ const buildCspHeaders = (nonce: string) => { ], "form-action": ["'self'"], "frame-ancestors": ["'none'"], - "report-uri": [getReportUri()], + "report-uri": [getReportUri(config)], }; - const cspString = Object.keys(baseCsp) - .map((policy) => { - const value = [...baseCsp[policy]]; + const cspString = Object.entries(baseCsp) + .map(([policy, baseValue]) => { + const value = [...baseValue]; - if (vercelPolicies[policy]) { - value.push(...vercelPolicies[policy]); - } - if (clerkPolicies[policy]) { - value.push(...clerkPolicies[policy]); - } - if (avoPolicies[policy]) { - value.push(...avoPolicies[policy]); - } - if (posthogPolicies[policy]) { - value.push(...posthogPolicies[policy]); - } - if (devConsentPolicies[policy]) { - value.push(...devConsentPolicies[policy]); - } - if (mux[policy]) { - value.push(...mux[policy]); + const additionalPolicies = [ + config.enabledPolicies.vercel ? vercelPolicies : {}, + config.enabledPolicies.clerk ? clerkPolicies : {}, + config.enabledPolicies.avo ? avoPolicies : {}, + config.enabledPolicies.posthog ? posthogPolicies : {}, + config.enabledPolicies.devConsent ? devConsentPolicies : {}, + config.enabledPolicies.mux ? mux : {}, + ]; + + for (const policyObject of additionalPolicies) { + const policyValue = policyObject[policy as keyof typeof policyObject]; + if (Array.isArray(policyValue)) { + value.push(...policyValue); + } } + return `${policy} ${value.join(" ")}`; }) .join(";"); - if (process.env.STRICT_CSP === "true") { + if (config.strictCsp) { return { - policy: addUpgradeInsecure(cspString), + policy: addUpgradeInsecure(cspString, config), }; } else { return { - policy: addUpgradeInsecure(legacyCspHeader), + policy: addUpgradeInsecure(legacyCspHeader, config), reportOnly: cspString, }; } @@ -193,26 +199,18 @@ const buildCspHeaders = (nonce: string) => { const OVERRIDE_HEADERS = "x-middleware-override-headers"; const MIDDLEWARE_HEADER_PREFIX = "x-middleware-request" as string; -// The nextjs CSP example passes request headers to the NextResponse.next() constructor -// We already have a NextResponse, so we need to replicate that behaviour -// See https://github.com/vercel/next.js/blob/918af1667aa0770088446952bc607b9a972e6128/packages/next/src/server/web/spec-extension/response.ts#L21-L27 -// Implementation copied from https://github.com/clerk/javascript/blob/c489ee1c95596af2e39636f02ee748e74011ce19/packages/nextjs/src/server/utils.ts#L80 const setRequestHeadersOnNextResponse = ( res: NextResponse | Response, req: Request, newHeaders: Record, ) => { if (!res.headers.get(OVERRIDE_HEADERS)) { - // Emulate a user setting overrides by explicitly adding the required nextjs headers - // https://github.com/vercel/next.js/pull/41380 - // @ts-expect-error Argument of type 'string[]' is not assignable to parameter of type 'string'.ts(2345) - res.headers.set(OVERRIDE_HEADERS, [...req.headers.keys()]); + res.headers.set(OVERRIDE_HEADERS, Array.from(req.headers.keys()).join(",")); req.headers.forEach((val, key) => { res.headers.set(`${MIDDLEWARE_HEADER_PREFIX}-${key}`, val); }); } - // Now that we have normalised res to include overrides, just append the new header Object.entries(newHeaders).forEach(([key, val]) => { res.headers.set( OVERRIDE_HEADERS, @@ -225,10 +223,10 @@ const setRequestHeadersOnNextResponse = ( export const addCspHeaders = ( response: Response, request: NextRequest, + config: CspConfig, ): Response => { if ( request.nextUrl.pathname.match( - // NOTE: We're keeping CSP headers for /api routes in case they return an HTML response like 404 /(_next\/static|_next\/image|favicon.ico)/, ) || request.headers.has("next-router-prefetch") || @@ -237,13 +235,13 @@ export const addCspHeaders = ( return response; } - const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); - const csp = buildCspHeaders(nonce); + const nonce = generateNonce(); + const csp = buildCspHeaders(nonce, config); + const newResponse = new NextResponse(response.body, response); - const headers = new Headers(response.headers); - headers.set("x-middleware-csp-nonce", nonce); + newResponse.headers.set("x-middleware-csp-nonce", nonce); - setRequestHeadersOnNextResponse(response, request, { + setRequestHeadersOnNextResponse(newResponse, request, { "x-nonce": nonce, "Content-Security-Policy": csp.policy, ...(csp.reportOnly && { @@ -251,10 +249,13 @@ export const addCspHeaders = ( }), }); - response.headers.set("Content-Security-Policy", csp.policy); + newResponse.headers.set("Content-Security-Policy", csp.policy); if (csp.reportOnly) { - response.headers.set("Content-Security-Policy-Report-Only", csp.reportOnly); + newResponse.headers.set( + "Content-Security-Policy-Report-Only", + csp.reportOnly, + ); } - return response; + return newResponse; }; diff --git a/apps/nextjs/src/middlewares/middlewareErrorLogging.ts b/apps/nextjs/src/middlewares/middlewareErrorLogging.ts new file mode 100644 index 000000000..b063a4931 --- /dev/null +++ b/apps/nextjs/src/middlewares/middlewareErrorLogging.ts @@ -0,0 +1,59 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextFetchEvent, NextRequest } from "next/server"; + +export async function logError( + rootError: unknown, + request: NextRequest, + event: NextFetchEvent, +): Promise { + const requestInfo = { + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers), + cookies: Object.fromEntries(request.cookies), + geo: request.geo, + ip: request.ip, + nextUrl: { + pathname: request.nextUrl.pathname, + search: request.nextUrl.search, + }, + }; + + const eventInfo = { + sourcePage: event.sourcePage, + }; + + let bodyText = ""; + try { + const clonedRequest = request.clone(); + bodyText = await clonedRequest.text(); + } catch (bodyError) { + bodyText = "Failed to read request body"; + } + + if (rootError instanceof SyntaxError) { + // Log the error so it is picked up by the log drain (eg. DataDog) + console.warn({ + event: "middleware.syntaxError", + error: rootError.message, + url: requestInfo.url, + request: requestInfo, + }); + // Do not log SyntaxErrors to Sentry + return; + } + + const wrappedError = + rootError instanceof Error + ? rootError + : new Error("Error in nextMiddleware", { cause: rootError }); + + Sentry.captureException(wrappedError, { + extra: { + requestInfo, + eventInfo, + bodyText, + errorType: "NextMiddlewareError", + }, + }); +} From e538d70474abdc479095483820692ce8ac3d806a Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Mon, 14 Oct 2024 12:59:11 +0200 Subject: [PATCH 2/9] test: add a delay after generation to let romans test settle (#220) --- .../tests/aila-chat/full-romans.test.ts | 144 +++++++++--------- 1 file changed, 73 insertions(+), 71 deletions(-) diff --git a/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts b/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts index 38d8144f4..de65ad42b 100644 --- a/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts +++ b/apps/nextjs/tests-e2e/tests/aila-chat/full-romans.test.ts @@ -1,5 +1,5 @@ import { setupClerkTestingToken } from "@clerk/testing/playwright"; -import { test, expect } from "@playwright/test"; +import { test, expect, Page } from "@playwright/test"; import { TEST_BASE_URL } from "../../config/config"; import { bypassVercelProtection } from "../../helpers/vercel"; @@ -18,73 +18,75 @@ import { // const FIXTURE_MODE = "record" as FixtureMode; const FIXTURE_MODE = "replay" as FixtureMode; -test.describe(() => { - // NOTE(2024-10-10): This test is flaky, with the "10 of 10" check often returning 6-8 sections - // Remove extra retries when it becomes more stable - if (process.env.CI === "true") { - test.describe.configure({ retries: 4 }); - } - - test( - "Full aila flow with Romans fixture", - { tag: "@common-auth" }, - async ({ page }) => { - const generationTimeout = FIXTURE_MODE === "record" ? 75000 : 50000; - test.setTimeout(generationTimeout * 5); - - await test.step("Setup", async () => { - await bypassVercelProtection(page); - await setupClerkTestingToken({ page }); - - await page.goto(`${TEST_BASE_URL}/aila`); - await expect(page.getByTestId("chat-h1")).toBeInViewport(); - }); - - const { setFixture } = await applyLlmFixtures(page, FIXTURE_MODE); - - await test.step("Fill in the chat box", async () => { - const textbox = page.getByTestId("chat-input"); - const sendMessage = page.getByTestId("send-message"); - const message = - "Create a KS1 lesson on the end of Roman Britain. Ask a question for each quiz and cycle"; - await textbox.fill(message); - await expect(textbox).toContainText(message); - - // Temporary fix: The test goes quicker than a real user and submits before the demo status has loaded - // This means that a demo modal would be shown when submitting - await page.waitForTimeout(500); - - setFixture("roman-britain-1"); - await sendMessage.click(); - }); - - await test.step("Iterate through the fixtures", async () => { - await page.waitForURL(/\/aila\/.+/); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 1); - - setFixture("roman-britain-2"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 3); - - setFixture("roman-britain-3"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 7); - - setFixture("roman-britain-4"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 10); - - setFixture("roman-britain-5"); - await continueChat(page); - await waitForGeneration(page, generationTimeout); - await expectSectionsComplete(page, 10); - - await expectFinished(page); - }); - }, - ); -}); +test( + "Full aila flow with Romans fixture", + { tag: "@common-auth" }, + async ({ page }, testInfo) => { + const generationTimeout = FIXTURE_MODE === "record" ? 75000 : 50000; + test.setTimeout(generationTimeout * 5); + + // The chat UI has a race condition when you submit a message too quickly after the previous response + // This is a temporary fix to fix test flake + async function letUiSettle() { + return await page.waitForTimeout(testInfo.retry === 0 ? 500 : 6000); + } + + await test.step("Setup", async () => { + await bypassVercelProtection(page); + await setupClerkTestingToken({ page }); + + await page.goto(`${TEST_BASE_URL}/aila`); + await expect(page.getByTestId("chat-h1")).toBeInViewport(); + }); + + const { setFixture } = await applyLlmFixtures(page, FIXTURE_MODE); + + await test.step("Fill in the chat box", async () => { + const textbox = page.getByTestId("chat-input"); + const sendMessage = page.getByTestId("send-message"); + const message = + "Create a KS1 lesson on the end of Roman Britain. Ask a question for each quiz and cycle"; + await textbox.fill(message); + await expect(textbox).toContainText(message); + + // Temporary fix: The test goes quicker than a real user and submits before the demo status has loaded + // This means that a demo modal would be shown when submitting + await page.waitForTimeout(500); + + setFixture("roman-britain-1"); + await sendMessage.click(); + }); + + await test.step("Iterate through the fixtures", async () => { + await page.waitForURL(/\/aila\/.+/); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 1); + await letUiSettle(); + + setFixture("roman-britain-2"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 3); + await letUiSettle(); + + setFixture("roman-britain-3"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 7); + await letUiSettle(); + + setFixture("roman-britain-4"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 10); + await letUiSettle(); + + setFixture("roman-britain-5"); + await continueChat(page); + await waitForGeneration(page, generationTimeout); + await expectSectionsComplete(page, 10); + + await expectFinished(page); + }); + }, +); From a94e0f4570b0bfbd6642d6cb36380b5eaf4b2e52 Mon Sep 17 00:00:00 2001 From: Stef Lewandowski Date: Mon, 14 Oct 2024 12:09:14 +0100 Subject: [PATCH 3/9] fix: findLast is not available for all browsers (WIP) (#211) Co-authored-by: Adam Howard <91115+codeincontext@users.noreply.github.com> --- .../AppComponents/Chat/chat-quick-buttons.tsx | 4 +++- .../nextjs/src/lib/hooks/use-enter-submit.tsx | 24 +++++++++++-------- .../helpers/chat/getLastAssistantMessage.ts | 6 +++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx index 5558c322d..3b8a35b52 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-quick-buttons.tsx @@ -1,5 +1,7 @@ import { useCallback } from "react"; +import { findLast } from "remeda"; + import { useLessonChat } from "@/components/ContextProviders/ChatProvider"; import { Icon } from "@/components/Icon"; import { useLessonPlanTracking } from "@/lib/analytics/lessonPlanTrackingContext"; @@ -61,7 +63,7 @@ const QuickActionButtons = ({ isEmptyScreen }: QuickActionButtonsProps) => { const handleRegenerate = useCallback(() => { trackEvent("chat:regenerate", { id: id }); const lastUserMessage = - messages.findLast((m) => m.role === "user")?.content || ""; + findLast(messages, (m) => m.role === "user")?.content || ""; lessonPlanTracking.onClickRetry(lastUserMessage); queueUserAction("regenerate"); }, [queueUserAction, lessonPlanTracking, messages, trackEvent, id]); diff --git a/apps/nextjs/src/lib/hooks/use-enter-submit.tsx b/apps/nextjs/src/lib/hooks/use-enter-submit.tsx index d66b2d325..9ea937c9e 100644 --- a/apps/nextjs/src/lib/hooks/use-enter-submit.tsx +++ b/apps/nextjs/src/lib/hooks/use-enter-submit.tsx @@ -1,23 +1,27 @@ -import { useRef, type RefObject } from 'react' +import { useRef, type RefObject } from "react"; export function useEnterSubmit(): { - formRef: RefObject - onKeyDown: (event: React.KeyboardEvent) => void + formRef: RefObject; + onKeyDown: (event: React.KeyboardEvent) => void; } { - const formRef = useRef(null) + const formRef = useRef(null); const handleKeyDown = ( - event: React.KeyboardEvent + event: React.KeyboardEvent, ): void => { if ( - event.key === 'Enter' && + event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing ) { - formRef.current?.requestSubmit() - event.preventDefault() + try { + formRef.current?.requestSubmit(); + } catch (error) { + console.error("Failed to submit form:", error); + } + event.preventDefault(); } - } + }; - return { formRef, onKeyDown: handleKeyDown } + return { formRef, onKeyDown: handleKeyDown }; } diff --git a/packages/aila/src/helpers/chat/getLastAssistantMessage.ts b/packages/aila/src/helpers/chat/getLastAssistantMessage.ts index 8f1065f94..1608243f7 100644 --- a/packages/aila/src/helpers/chat/getLastAssistantMessage.ts +++ b/packages/aila/src/helpers/chat/getLastAssistantMessage.ts @@ -1,4 +1,5 @@ import type { Message as AiMessage } from "ai"; +import { findLast } from "remeda"; import type { Message as AilaMessage } from "../../core/chat/types"; @@ -12,9 +13,10 @@ interface AssistantMessage extends AilaMessage { export function getLastAssistantMessage( messages: AiMessage[], ): AssistantMessage | undefined { - const lastAssistantMessage = messages.findLast( + const lastAssistantMessage = findLast( + messages, (m): m is AssistantMessage => m.role === "assistant", - ); + ) as AssistantMessage | undefined; return lastAssistantMessage; } From f002d968be3fc3c01e96a22dd6e69ce9ee0f5e4f Mon Sep 17 00:00:00 2001 From: MG Date: Tue, 15 Oct 2024 09:43:03 +0100 Subject: [PATCH 4/9] chore: fix testid casing and remove unused async (#209) Co-authored-by: Adam Howard <91115+codeincontext@users.noreply.github.com> --- apps/nextjs/src/app/aila/[id]/share/index.tsx | 2 +- .../src/components/AppComponents/Chat/export-buttons/index.tsx | 2 +- apps/nextjs/tests-e2e/tests/aila-chat/helpers.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/nextjs/src/app/aila/[id]/share/index.tsx b/apps/nextjs/src/app/aila/[id]/share/index.tsx index b9a5a2de3..833238906 100644 --- a/apps/nextjs/src/app/aila/[id]/share/index.tsx +++ b/apps/nextjs/src/app/aila/[id]/share/index.tsx @@ -77,7 +77,7 @@ export default function ShareChat({

{lessonPlan.title}

{ navigator.clipboard.writeText(window.location.href).then(() => { setUserHasCopiedLink(true); diff --git a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx index 3b3ff36a0..42276d2e7 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/export-buttons/index.tsx @@ -56,7 +56,7 @@ const ExportButtons = ({ { diff --git a/apps/nextjs/tests-e2e/tests/aila-chat/helpers.ts b/apps/nextjs/tests-e2e/tests/aila-chat/helpers.ts index 5c1f6f93a..a4f2b2b40 100644 --- a/apps/nextjs/tests-e2e/tests/aila-chat/helpers.ts +++ b/apps/nextjs/tests-e2e/tests/aila-chat/helpers.ts @@ -52,7 +52,7 @@ export const applyLlmFixtures = async ( }); return { - setFixture: async (name: string) => { + setFixture: (name: string) => { fixtureName = name; }, }; From dccba587fe8b5b06b4e2a34a92c45c9aa7597851 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:51:02 +0200 Subject: [PATCH 5/9] fix: revert "fix: revert error to previous protocol (#139) (#218) --- apps/nextjs/src/app/api/chat/errorHandling.test.ts | 14 ++++++++------ apps/nextjs/src/app/api/chat/protocol.ts | 3 ++- apps/nextjs/src/utils/testHelpers/consumeStream.ts | 9 +++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/nextjs/src/app/api/chat/errorHandling.test.ts b/apps/nextjs/src/app/api/chat/errorHandling.test.ts index 45a4ad2f8..573c8550c 100644 --- a/apps/nextjs/src/app/api/chat/errorHandling.test.ts +++ b/apps/nextjs/src/app/api/chat/errorHandling.test.ts @@ -6,7 +6,10 @@ import { RateLimitExceededError } from "@oakai/core/src/utils/rateLimiting/userB import { PrismaClientWithAccelerate } from "@oakai/db"; import invariant from "tiny-invariant"; -import { consumeStream } from "@/utils/testHelpers/consumeStream"; +import { + consumeStream, + extractStreamMessage, +} from "@/utils/testHelpers/consumeStream"; import { handleChatException } from "./errorHandling"; @@ -35,7 +38,7 @@ describe("handleChatException", () => { expect(response.status).toBe(200); invariant(response.body instanceof ReadableStream); - const message = JSON.parse(await consumeStream(response.body)); + const message = extractStreamMessage(await consumeStream(response.body)); expect(message).toEqual({ type: "error", @@ -85,8 +88,7 @@ describe("handleChatException", () => { expect(response.status).toBe(200); const consumed = await consumeStream(response.body as ReadableStream); - expect(consumed).toMatch(/^\{/); - const message = JSON.parse(consumed); + const message = extractStreamMessage(consumed); expect(message).toEqual({ type: "error", @@ -112,7 +114,7 @@ describe("handleChatException", () => { expect(response.status).toBe(200); - const message = JSON.parse( + const message = extractStreamMessage( await consumeStream(response.body as ReadableStream), ); expect(message).toEqual({ @@ -121,4 +123,4 @@ describe("handleChatException", () => { }); }); }); -}); +}); \ No newline at end of file diff --git a/apps/nextjs/src/app/api/chat/protocol.ts b/apps/nextjs/src/app/api/chat/protocol.ts index b6af4e645..322d43d6a 100644 --- a/apps/nextjs/src/app/api/chat/protocol.ts +++ b/apps/nextjs/src/app/api/chat/protocol.ts @@ -5,7 +5,8 @@ import { import { StreamingTextResponse } from "ai"; export function streamingJSON(message: ErrorDocument | ActionDocument) { - const errorMessage = JSON.stringify(message); + const jsonContent = JSON.stringify(message); + const errorMessage = `0:"${jsonContent.replace(/"/g, '\\"')}"`; const errorEncoder = new TextEncoder(); diff --git a/apps/nextjs/src/utils/testHelpers/consumeStream.ts b/apps/nextjs/src/utils/testHelpers/consumeStream.ts index 27a045af9..6f3837692 100644 --- a/apps/nextjs/src/utils/testHelpers/consumeStream.ts +++ b/apps/nextjs/src/utils/testHelpers/consumeStream.ts @@ -14,3 +14,12 @@ export async function consumeStream( return result; } + +export function extractStreamMessage(streamedText: string) { + const content = streamedText.match(/0:"(.*)"/); + if (!content?.[1]) { + throw new Error("No message found in streamed text"); + } + const strippedContent = content[1].replace(/\\"/g, '"'); + return JSON.parse(strippedContent); +} From d1b0619f2c9f0843eed30f8fa669b2c85865df95 Mon Sep 17 00:00:00 2001 From: Adam Howard <91115+codeincontext@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:01:29 +0200 Subject: [PATCH 6/9] test: skip auth test failing from missing test@ account (#230) --- apps/nextjs/tests-e2e/tests/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/nextjs/tests-e2e/tests/auth.test.ts b/apps/nextjs/tests-e2e/tests/auth.test.ts index 993d4a774..a525b3d1d 100644 --- a/apps/nextjs/tests-e2e/tests/auth.test.ts +++ b/apps/nextjs/tests-e2e/tests/auth.test.ts @@ -30,7 +30,7 @@ async function signInThroughUI(page: Page) { await page.getByRole("button", { name: "Continue", exact: true }).click(); } -test("authenticate through Clerk UI", async ({ page }) => { +test.skip("authenticate through Clerk UI", async ({ page }) => { await bypassVercelProtection(page); await page.context().clearCookies(); From e9b02c61deaec766bde6bf8494e7b724946f05e3 Mon Sep 17 00:00:00 2001 From: Stef Lewandowski Date: Tue, 15 Oct 2024 12:11:09 +0100 Subject: [PATCH 7/9] fix: add fallback if requestSubmit is not available (#227) --- apps/nextjs/src/lib/hooks/use-enter-submit.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/nextjs/src/lib/hooks/use-enter-submit.tsx b/apps/nextjs/src/lib/hooks/use-enter-submit.tsx index 9ea937c9e..8afc84b5d 100644 --- a/apps/nextjs/src/lib/hooks/use-enter-submit.tsx +++ b/apps/nextjs/src/lib/hooks/use-enter-submit.tsx @@ -15,7 +15,13 @@ export function useEnterSubmit(): { !event.nativeEvent.isComposing ) { try { - formRef.current?.requestSubmit(); + if (formRef.current?.requestSubmit) { + formRef.current.requestSubmit(); + } else if (formRef.current?.submit) { + formRef.current.submit(); + } else { + throw new Error("Form submission not supported"); + } } catch (error) { console.error("Failed to submit form:", error); } From 2e3e56bbc9c0c8dd2f6d3bf6be9b167eb445ee97 Mon Sep 17 00:00:00 2001 From: Stef Lewandowski Date: Tue, 15 Oct 2024 12:11:34 +0100 Subject: [PATCH 8/9] fix: silence unknown part type errors (#229) --- .../AppComponents/Chat/chat-message/index.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx index 2a8fbb4cf..2a025e86c 100644 --- a/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx +++ b/apps/nextjs/src/components/AppComponents/Chat/chat-message/index.tsx @@ -13,6 +13,7 @@ import { PromptDocument, StateDocument, TextDocument, + UnknownDocument, parseMessageParts, } from "@oakai/aila/src/protocol/jsonPatchProtocol"; import { isSafe } from "@oakai/core/src/utils/ailaModeration/helpers"; @@ -234,10 +235,14 @@ function ChatMessagePart({ action: ActionMessagePart, moderation: ModerationMessagePart, id: IdMessagePart, - }[part.document.type]; + unknown: UnknownMessagePart, + }[part.document.type] as React.ComponentType<{ + part: typeof part.document; + moderationModalHelpers: ModerationModalHelpers; + }>; if (!PartComponent) { - console.log("Unknown part type", part.document.type, part); // eslint-disable-line no-console + console.log("Unknown part type", part.document.type, JSON.stringify(part)); // eslint-disable-line no-console return null; } @@ -311,6 +316,11 @@ function ActionMessagePart({ return null; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function UnknownMessagePart({ part }: Readonly<{ part: UnknownDocument }>) { + return null; +} + function PartInspector({ part }: Readonly<{ part: MessagePart }>) { return (
From dc747c3dd0a496f38b55846b40a787da8e92e05a Mon Sep 17 00:00:00 2001 From: Stef Lewandowski Date: Tue, 15 Oct 2024 12:11:55 +0100 Subject: [PATCH 9/9] fix: remove lookbehind in regex for camelcase conversion (#226) --- packages/core/src/utils/camelCaseToSentenceCase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/camelCaseToSentenceCase.ts b/packages/core/src/utils/camelCaseToSentenceCase.ts index 79b81c414..257769957 100644 --- a/packages/core/src/utils/camelCaseToSentenceCase.ts +++ b/packages/core/src/utils/camelCaseToSentenceCase.ts @@ -2,5 +2,5 @@ export function camelCaseToSentenceCase(str: string) { return str .replace(/([A-Z0-9])/g, " $1") // Insert a space before each uppercase letter or digit .replace(/^./, (str) => str.toUpperCase()) - .replace(/(?<=\s)[A-Z]/g, (str) => str.toLowerCase()); + .replace(/\s[A-Z]/g, (str) => str.toLowerCase()); }