Repository: auth0/nextjs-auth0
Pull request #1489 (2023-10-12 11:29:53)

Mock Next.js in page router tests
### 📋 Changes

Mock out the next test app for page router tests - since standing up a next app for every test is too expensive for the CI (didn't work in GH actions and was flakey on CircleCI)

Before:
<img width="953" alt="image" src="https://github.com/auth0/nextjs-auth0/assets/1299658/584d6cfb-ca69-44b8-9509-8f535312b62c">

After:
<img width="935" alt="image" src="https://github.com/auth0/nextjs-auth0/assets/1299658/3336fff9-5b35-48ff-a6e5-95c67bdcadf5">

### 🎯 Testing

Tested on GH actions https://github.com/auth0/nextjs-auth0/actions/runs/6494889625/job/17638727581



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c24337e9..f302efac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,6 @@ Please read [Auth0's contribution guidelines](https://github.com/auth0/open-sour
 
 - `npm install`: install dependencies
 - `npm run build`: Build the binary
-- `npm run build:test`: Do this once to build the test harness for the tests
 - `npm test`: Run the unit tests
 - `npm run test:watch`: Run the unit tests and watch for changes
 - `npm run install:example`: Install the examples
diff --git a/jest-base.config.js b/jest-base.config.js
index 65bd5b05..6c7a478a 100644
--- a/jest-base.config.js
+++ b/jest-base.config.js
@@ -3,7 +3,6 @@ module.exports = {
   rootDir: '.',
   moduleFileExtensions: ['ts', 'tsx', 'js'],
   preset: 'ts-jest/presets/js-with-ts',
-  globalSetup: './tests/global-setup.ts',
   setupFilesAfterEnv: ['./tests/setup.ts'],
   transformIgnorePatterns: ['/node_modules/(?!oauth4webapi)']
 };
diff --git a/package.json b/package.json
index fe4cdfc1..ec0c1151 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,6 @@
     "prepack": "npm run build",
     "install:example": "npm i --prefix=example-app --no-package-lock",
     "build": "npm run clean && tsc -p tsconfig.build.json",
-    "build:test": "next build tests/fixtures/test-app",
     "build:example": "npm run build --prefix=example-app",
     "build:vercel": "npm run install:example && npm run build && npm run build:example",
     "start:example": "npm run dev --prefix=example-app",


Test patch:
diff --git a/tests/auth0-session/fixtures/server.ts b/tests/auth0-session/fixtures/server.ts
index 5277bb96..e9963798 100644
--- a/tests/auth0-session/fixtures/server.ts
+++ b/tests/auth0-session/fixtures/server.ts
@@ -4,7 +4,6 @@ import { createServer as createHttpsServer, Server as HttpsServer } from 'https'
 import url from 'url';
 import nock from 'nock';
 import { TokenSet, TokenSetParameters } from 'openid-client';
-import bodyParser from 'body-parser';
 import {
   loginHandler,
   getConfig,
@@ -94,9 +93,10 @@ const createHandlers = (params: ConfigParameters): Handlers => {
   };
 };
 
-const jsonParse = bodyParser.json();
-const parseJson = (req: IncomingMessage, res: ServerResponse): Promise<IncomingMessage> =>
-  new Promise((resolve, reject) => {
+export const parseJson = async (req: IncomingMessage, res: ServerResponse): Promise<IncomingMessage> => {
+  const { default: bodyParser } = await import('body-parser');
+  const jsonParse = bodyParser.json();
+  return await new Promise((resolve, reject) => {
     jsonParse(req, res, (error: Error | undefined) => {
       if (error) {
         reject(error);
@@ -105,6 +105,7 @@ const parseJson = (req: IncomingMessage, res: ServerResponse): Promise<IncomingM
       }
     });
   });
+};
 
 const requestListener =
   (
diff --git a/tests/fixtures/server.ts b/tests/fixtures/server.ts
index 39895080..e9097732 100644
--- a/tests/fixtures/server.ts
+++ b/tests/fixtures/server.ts
@@ -1,18 +1,115 @@
-import { createServer as createHttpServer, Server } from 'http';
-import next from 'next';
-import * as path from 'path';
-import { parse } from 'url';
+import { createServer as createHttpServer, IncomingMessage, Server, ServerResponse } from 'http';
+import { NextApiRequest, NextApiResponse } from 'next';
+import * as qs from 'querystring';
+import * as cookie from 'cookie';
 import { AddressInfo } from 'net';
+import { parseJson } from '../auth0-session/fixtures/server';
 
 let server: Server;
 
+const toNextApiRequest = async (req: IncomingMessage): Promise<NextApiRequest> => {
+  const parsedReq = await parseJson(req, new ServerResponse(req));
+  const apiReq = parsedReq as NextApiRequest;
+  apiReq.query = qs.parse(new URL(req.url!, 'http://example.com').search.slice(1));
+  apiReq.cookies = cookie.parse((req.headers.cookie as string) || '');
+  return apiReq;
+};
+
+const toNextApiResponse = async (res: ServerResponse): Promise<NextApiResponse> => {
+  const apiRes = res as NextApiResponse;
+
+  apiRes.status = (statusCode) => {
+    apiRes.statusCode = statusCode;
+    return apiRes;
+  };
+  apiRes.send = apiRes.end.bind(apiRes);
+  apiRes.json = (data) => {
+    apiRes.setHeader('Content-Type', 'application/json; charset=utf-8');
+    apiRes.send(JSON.stringify(data));
+  };
+  apiRes.redirect = (statusOrUrl: string | number, url?: string) => {
+    if (typeof statusOrUrl === 'string') {
+      url = statusOrUrl;
+      statusOrUrl = 307;
+    }
+    apiRes.writeHead(statusOrUrl, { Location: url });
+    apiRes.write(url);
+    apiRes.end();
+    return apiRes;
+  };
+
+  return apiRes;
+};
+
+const handle = async (req: NextApiRequest, res: NextApiResponse) => {
+  const [path] = req.url!.split('?');
+  if (path.startsWith('/api/auth')) {
+    req.query.auth0 = path.split('/').slice(3);
+    await (global.handleAuth?.())(req, res);
+    return;
+  }
+  switch (path) {
+    case '/api/access-token':
+      {
+        try {
+          const json = await global.getAccessToken?.(req, res);
+          res.status(200).json(json);
+        } catch (error) {
+          res.statusMessage = error.message;
+          res.status(error.status || 500).end(error.message);
+        }
+      }
+      break;
+    case '/api/protected':
+      {
+        (
+          await global.withApiAuthRequired?.(function protectedApiRoute() {
+            res.status(200).json({ foo: 'bar' });
+          })
+        )(req, res);
+      }
+      break;
+    case '/api/session':
+      {
+        const json = await global.getSession?.(req, res);
+        res.status(200).json(json);
+      }
+      break;
+    case '/api/touch-session':
+      {
+        await global.touchSession?.(req, res);
+        const json = await global.getSession?.(req, res);
+        res.status(200).json(json);
+      }
+      break;
+    case '/api/update-session':
+      {
+        const session = await global.getSession?.(req, res);
+        const updated = { ...session, ...req.body?.session };
+        await global.updateSession?.(req, res, updated);
+        res.status(200).json(updated);
+      }
+      break;
+    case '/protected':
+      const ret = await global.withPageAuthRequired?.()({ req, res, resolvedUrl: path });
+      if (ret.redirect) {
+        res.redirect(ret.redirect.destination);
+      } else {
+        const user = (await ret.props).user;
+        res.send(`<div>Protected Page ${user ? user.sub : ''}</div>`);
+      }
+      break;
+    default:
+      res.status(418).end();
+      return;
+  }
+};
+
 export const start = async (): Promise<string> => {
-  const app = next({ dev: false, dir: path.join(__dirname, 'test-app') });
-  await app.prepare();
-  const handle = app.getRequestHandler();
   server = createHttpServer(async (req, res) => {
-    const parsedUrl = parse(req.url as string, true);
-    await handle(req, res, parsedUrl);
+    const apiReq = await toNextApiRequest(req);
+    const apiRes = await toNextApiResponse(res);
+    await handle(apiReq, apiRes);
   });
   const port = await new Promise((resolve) => server.listen(0, () => resolve((server.address() as AddressInfo).port)));
   return `http://localhost:${port}`;
diff --git a/tests/fixtures/test-app/next-env.d.ts b/tests/fixtures/test-app/next-env.d.ts
deleted file mode 100644
index 4f11a03d..00000000
--- a/tests/fixtures/test-app/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-/// <reference types="next" />
-/// <reference types="next/image-types/global" />
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/tests/fixtures/test-app/pages/_document.tsx b/tests/fixtures/test-app/pages/_document.tsx
deleted file mode 100644
index 2d47e0e6..00000000
--- a/tests/fixtures/test-app/pages/_document.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-import Document, { DocumentContext, DocumentInitialProps } from 'next/document';
-
-class MyDocument extends Document {
-  static getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> {
-    return Document.getInitialProps(ctx);
-  }
-
-  render(): React.ReactElement {
-    return <div>Blank Document</div>;
-  }
-}
-
-export default MyDocument;
diff --git a/tests/fixtures/test-app/pages/api/access-token.ts b/tests/fixtures/test-app/pages/api/access-token.ts
deleted file mode 100644
index 001d7750..00000000
--- a/tests/fixtures/test-app/pages/api/access-token.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-
-export default async function accessTokenHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
-  try {
-    const json = await global.getAccessToken?.(req, res);
-    res.status(200).json(json);
-  } catch (error) {
-    res.statusMessage = error.message;
-    res.status(error.status || 500).end(error.message);
-  }
-}
diff --git a/tests/fixtures/test-app/pages/api/auth/[...auth0].ts b/tests/fixtures/test-app/pages/api/auth/[...auth0].ts
deleted file mode 100644
index 508718b1..00000000
--- a/tests/fixtures/test-app/pages/api/auth/[...auth0].ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export default (...args: any) => global.handleAuth?.()(...args);
diff --git a/tests/fixtures/test-app/pages/api/protected.ts b/tests/fixtures/test-app/pages/api/protected.ts
deleted file mode 100644
index 6bf27ab1..00000000
--- a/tests/fixtures/test-app/pages/api/protected.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-
-export default global.withApiAuthRequired?.(function protectedApiRoute(_req: NextApiRequest, res: NextApiResponse) {
-  res.status(200).json({ foo: 'bar' });
-});
diff --git a/tests/fixtures/test-app/pages/api/session.ts b/tests/fixtures/test-app/pages/api/session.ts
deleted file mode 100644
index 5fc47247..00000000
--- a/tests/fixtures/test-app/pages/api/session.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-
-export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
-  const json = await global.getSession?.(req, res);
-  res.status(200).json(json);
-}
diff --git a/tests/fixtures/test-app/pages/api/touch-session.ts b/tests/fixtures/test-app/pages/api/touch-session.ts
deleted file mode 100644
index aa334578..00000000
--- a/tests/fixtures/test-app/pages/api/touch-session.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-
-export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
-  await global.touchSession?.(req, res);
-  const json = await global.getSession?.(req, res);
-  res.status(200).json(json);
-}
diff --git a/tests/fixtures/test-app/pages/api/update-session.ts b/tests/fixtures/test-app/pages/api/update-session.ts
deleted file mode 100644
index b44cdbf8..00000000
--- a/tests/fixtures/test-app/pages/api/update-session.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { NextApiRequest, NextApiResponse } from 'next';
-
-export default async function sessionHandler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
-  const session = await global.getSession?.(req, res);
-  const updated = { ...session, ...req.body?.session };
-  await global.updateSession?.(req, res, updated);
-  res.status(200).json(updated);
-}
diff --git a/tests/fixtures/test-app/pages/csr-protected.tsx b/tests/fixtures/test-app/pages/csr-protected.tsx
deleted file mode 100644
index 0978f503..00000000
--- a/tests/fixtures/test-app/pages/csr-protected.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react';
-import { NextPageContext } from 'next';
-
-// eslint-disable-next-line react/prop-types
-export default function protectedPage(): React.ReactElement {
-  return global.withPageAuthRequiredCSR?.(() => <div>Protected Page</div>);
-}
-
-export const getServerSideProps = (ctx: NextPageContext): any => global.withPageAuthRequired?.()(ctx);
diff --git a/tests/fixtures/test-app/pages/global.d.ts b/tests/fixtures/test-app/pages/global.d.ts
deleted file mode 100644
index 7ded08be..00000000
--- a/tests/fixtures/test-app/pages/global.d.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-import '../../global';
diff --git a/tests/fixtures/test-app/pages/index.tsx b/tests/fixtures/test-app/pages/index.tsx
deleted file mode 100644
index 3dbd7273..00000000
--- a/tests/fixtures/test-app/pages/index.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from 'react';
-
-export default function Home(): React.ReactElement {
-  return <div>Welcome to Next.js</div>;
-}
diff --git a/tests/fixtures/test-app/pages/protected.tsx b/tests/fixtures/test-app/pages/protected.tsx
deleted file mode 100644
index 316b8635..00000000
--- a/tests/fixtures/test-app/pages/protected.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from 'react';
-import { NextPageContext } from 'next';
-
-export default function protectedPage({ user }: { user?: { sub: string } }): React.ReactElement {
-  return <div>Protected Page {user ? user.sub : ''}</div>;
-}
-
-export const getServerSideProps = (ctx: NextPageContext): any => global.withPageAuthRequired?.()(ctx);
diff --git a/tests/fixtures/test-app/tsconfig.json b/tests/fixtures/test-app/tsconfig.json
deleted file mode 100644
index 5bee8c4d..00000000
--- a/tests/fixtures/test-app/tsconfig.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
-  "compilerOptions": {
-    "target": "es5",
-    "lib": [
-      "dom",
-      "dom.iterable",
-      "esnext"
-    ],
-    "allowJs": true,
-    "skipLibCheck": true,
-    "strict": false,
-    "forceConsistentCasingInFileNames": true,
-    "noEmit": true,
-    "esModuleInterop": true,
-    "module": "esnext",
-    "moduleResolution": "node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "preserve",
-    "incremental": true
-  },
-  "include": [
-    "next-env.d.ts",
-    "**/*.ts",
-    "**/*.tsx"
-  ],
-  "exclude": [
-    "node_modules"
-  ]
-}
diff --git a/tests/global-setup.ts b/tests/global-setup.ts
deleted file mode 100644
index 9deb46fc..00000000
--- a/tests/global-setup.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { exec } from 'child_process';
-import { promisify } from 'util';
-
-const execAsync = promisify(exec);
-
-export default async (): Promise<void> => {
-  if (process.env.CI) {
-    await execAsync('npm run build:test');
-  }
-};
diff --git a/tests/helpers/with-page-auth-required.test.ts b/tests/helpers/with-page-auth-required.test.ts
index 68c97111..32944969 100644
--- a/tests/helpers/with-page-auth-required.test.ts
+++ b/tests/helpers/with-page-auth-required.test.ts
@@ -170,15 +170,6 @@ describe('with-page-auth-required ssr', () => {
       delete process.env.NEXT_PUBLIC_AUTH0_LOGIN;
     });
 
-    test('is a no-op when invoked as a client-side protection from the server', async () => {
-      const baseUrl = await setup(withoutApi);
-      const cookieJar = await login(baseUrl);
-      const {
-        res: { statusCode }
-      } = await get(baseUrl, '/csr-protected', { cookieJar, fullResponse: true });
-      expect(statusCode).toBe(200);
-    });
-
     test('should preserve multiple query params in the returnTo URL', async () => {
       const baseUrl = await setup(withoutApi, { withPageAuthRequiredOptions: { returnTo: '/foo?bar=baz&qux=quux' } });
       const {