From 72f1c2e08ed9f3ec332df73da0ab485877297258 Mon Sep 17 00:00:00 2001
From: Paul Asjes
Date: Wed, 4 Dec 2024 12:46:36 +0100
Subject: [PATCH] 100% test coverage
---
__tests__/authkit-callback-route.spec.ts | 14 +++
__tests__/min-max-button.spec.tsx | 13 +++
__tests__/session.spec.ts | 37 ++++++-
__tests__/utils.spec.ts | 132 +++++++++++++++++++++++
__tests__/workos.spec.ts | 66 ++++++++++++
jest.config.ts | 9 ++
src/authkit-callback-route.ts | 25 +----
src/session.ts | 12 +--
src/utils.ts | 20 ++++
tsconfig.json | 4 +-
10 files changed, 301 insertions(+), 31 deletions(-)
create mode 100644 __tests__/utils.spec.ts
create mode 100644 __tests__/workos.spec.ts
create mode 100644 src/utils.ts
diff --git a/__tests__/authkit-callback-route.spec.ts b/__tests__/authkit-callback-route.spec.ts
index 3360210..f074643 100644
--- a/__tests__/authkit-callback-route.spec.ts
+++ b/__tests__/authkit-callback-route.spec.ts
@@ -78,6 +78,20 @@ describe('authkit-callback-route', () => {
expect(data.error.message).toBe('Something went wrong');
});
+ it('should handle authentication failure if a non-Error object is thrown', async () => {
+ // Mock authentication failure
+ (workos.userManagement.authenticateWithCode as jest.Mock).mockRejectedValue('Auth failed');
+
+ request.nextUrl.searchParams.set('code', 'invalid-code');
+
+ const handler = handleAuth();
+ const response = await handler(request);
+
+ expect(response.status).toBe(500);
+ const data = await response.json();
+ expect(data.error.message).toBe('Something went wrong');
+ });
+
it('should handle missing code parameter', async () => {
const handler = handleAuth();
const response = await handler(request);
diff --git a/__tests__/min-max-button.spec.tsx b/__tests__/min-max-button.spec.tsx
index 0c304f3..8628e2e 100644
--- a/__tests__/min-max-button.spec.tsx
+++ b/__tests__/min-max-button.spec.tsx
@@ -26,6 +26,19 @@ describe('MinMaxButton', () => {
expect(root).toHaveStyle({ '--wi-minimized': '1' });
});
+ it('does nothing if root is undefined', () => {
+ const { getByRole } = render(Minimize);
+
+ const root = document.querySelector('[data-workos-impersonation-root]');
+
+ // Mock querySelector to return null for this test
+ jest.spyOn(document, 'querySelector').mockReturnValue(null);
+
+ const button = getByRole('button');
+ fireEvent.click(button);
+ expect(root).not.toHaveStyle({ '--wi-minimized': '1' });
+ });
+
it('renders children correctly', () => {
const { getByText } = render(Test Child);
diff --git a/__tests__/session.spec.ts b/__tests__/session.spec.ts
index a55c543..7616119 100644
--- a/__tests__/session.spec.ts
+++ b/__tests__/session.spec.ts
@@ -58,11 +58,14 @@ describe('session.ts', () => {
(jwtVerify as jest.Mock).mockReset();
- consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+ consoleLogSpy = jest.spyOn(console, 'log').mockImplementation((...args) => {
+ console.info(...args);
+ });
});
afterEach(() => {
consoleLogSpy.mockRestore();
+ jest.resetModules();
});
describe('withAuth', () => {
@@ -392,6 +395,38 @@ describe('session.ts', () => {
}).rejects.toThrow();
});
+ it('should throw an error if the provided regex is invalid and a non-Error object is thrown', async () => {
+ // Reset modules to ensure clean import state
+ jest.resetModules();
+
+ // Import first, then spy
+ const pathToRegexp = await import('path-to-regexp');
+ const parseSpy = jest.spyOn(pathToRegexp, 'parse').mockImplementation(() => {
+ throw 'invalid regex';
+ });
+
+ // Import session after setting up the spy
+ const { updateSession } = await import('../src/session.js');
+
+ const request = new NextRequest(new URL('http://example.com/invalid-regex'));
+
+ await expect(async () => {
+ await updateSession(
+ request,
+ false,
+ {
+ enabled: true,
+ unauthenticatedPaths: ['[*'],
+ },
+ process.env.NEXT_PUBLIC_WORKOS_REDIRECT_URI as string,
+ [],
+ );
+ }).rejects.toThrow('Error parsing routes for middleware auth. Reason: invalid regex');
+
+ // Verify the mock was called
+ expect(parseSpy).toHaveBeenCalled();
+ });
+
it('should default to the WORKOS_REDIRECT_URI environment variable if no redirect URI is provided', async () => {
const request = new NextRequest(new URL('http://example.com/protected'));
const result = await updateSession(
diff --git a/__tests__/utils.spec.ts b/__tests__/utils.spec.ts
new file mode 100644
index 0000000..e54687f
--- /dev/null
+++ b/__tests__/utils.spec.ts
@@ -0,0 +1,132 @@
+import { NextResponse } from 'next/server';
+import { redirectWithFallback, errorResponseWithFallback } from '../src/utils.js';
+
+describe('utils', () => {
+ afterEach(() => {
+ jest.resetModules();
+ });
+
+ describe('redirectWithFallback', () => {
+ it('uses NextResponse.redirect when available', () => {
+ const redirectUrl = 'https://example.com';
+ const mockRedirect = jest.fn().mockReturnValue('redirected');
+ const originalRedirect = NextResponse.redirect;
+
+ NextResponse.redirect = mockRedirect;
+
+ const result = redirectWithFallback(redirectUrl);
+
+ expect(mockRedirect).toHaveBeenCalledWith(redirectUrl);
+ expect(result).toBe('redirected');
+
+ NextResponse.redirect = originalRedirect;
+ });
+
+ it('falls back to standard Response when NextResponse exists but redirect is undefined', async () => {
+ const redirectUrl = 'https://example.com';
+
+ jest.resetModules();
+
+ jest.mock('next/server', () => ({
+ NextResponse: {
+ // exists but has no redirect method
+ },
+ }));
+
+ const { redirectWithFallback } = await import('../src/utils.js');
+
+ const result = redirectWithFallback(redirectUrl);
+
+ expect(result).toBeInstanceOf(Response);
+ expect(result.status).toBe(307);
+ expect(result.headers.get('Location')).toBe(redirectUrl);
+ });
+
+ it('falls back to standard Response when NextResponse is undefined', async () => {
+ const redirectUrl = 'https://example.com';
+
+ jest.resetModules();
+
+ // Mock with undefined NextResponse
+ jest.mock('next/server', () => ({
+ NextResponse: undefined,
+ }));
+
+ const { redirectWithFallback } = await import('../src/utils.js');
+
+ const result = redirectWithFallback(redirectUrl);
+
+ expect(result).toBeInstanceOf(Response);
+ expect(result.status).toBe(307);
+ expect(result.headers.get('Location')).toBe(redirectUrl);
+ });
+ });
+
+ describe('errorResponseWithFallback', () => {
+ const errorBody = {
+ error: {
+ message: 'Test error',
+ description: 'Test description',
+ },
+ };
+
+ it('uses NextResponse.json when available', () => {
+ const mockJson = jest.fn().mockReturnValue('error json response');
+ NextResponse.json = mockJson;
+
+ const result = errorResponseWithFallback(errorBody);
+
+ expect(mockJson).toHaveBeenCalledWith(errorBody, { status: 500 });
+ expect(result).toBe('error json response');
+ });
+
+ it('falls back to standard Response when NextResponse is not available', () => {
+ const originalJson = NextResponse.json;
+
+ // @ts-expect-error - This is to test the fallback
+ delete NextResponse.json;
+
+ const result = errorResponseWithFallback(errorBody);
+
+ expect(result).toBeInstanceOf(Response);
+ expect(result.status).toBe(500);
+ expect(result.headers.get('Content-Type')).toBe('application/json');
+
+ NextResponse.json = originalJson;
+ });
+
+ it('falls back to standard Response when NextResponse exists but json is undefined', async () => {
+ jest.resetModules();
+
+ jest.mock('next/server', () => ({
+ NextResponse: {
+ // exists but has no json method
+ },
+ }));
+
+ const { errorResponseWithFallback } = await import('../src/utils.js');
+
+ const result = errorResponseWithFallback(errorBody);
+
+ expect(result).toBeInstanceOf(Response);
+ expect(result.status).toBe(500);
+ expect(result.headers.get('Content-Type')).toBe('application/json');
+ });
+
+ it('falls back to standard Response when NextResponse is undefined', async () => {
+ jest.resetModules();
+
+ jest.mock('next/server', () => ({
+ NextResponse: undefined,
+ }));
+
+ const { errorResponseWithFallback } = await import('../src/utils.js');
+
+ const result = errorResponseWithFallback(errorBody);
+
+ expect(result).toBeInstanceOf(Response);
+ expect(result.status).toBe(500);
+ expect(result.headers.get('Content-Type')).toBe('application/json');
+ });
+ });
+});
diff --git a/__tests__/workos.spec.ts b/__tests__/workos.spec.ts
new file mode 100644
index 0000000..26a1db0
--- /dev/null
+++ b/__tests__/workos.spec.ts
@@ -0,0 +1,66 @@
+import { WorkOS } from '@workos-inc/node';
+import { workos, VERSION } from '../src/workos.js';
+
+describe('workos', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('initializes WorkOS with the correct configuration', () => {
+ // Extracting the config to avoid a circular dependency error
+ const workosConfig = {
+ apiHostname: workos.options.apiHostname,
+ https: workos.options.https,
+ port: workos.options.port,
+ appInfo: workos.options.appInfo,
+ };
+
+ expect(workosConfig).toEqual({
+ apiHostname: undefined,
+ https: true,
+ port: undefined,
+ appInfo: {
+ name: 'authkit/nextjs',
+ version: VERSION,
+ },
+ });
+ });
+
+ it('exports a WorkOS instance', () => {
+ expect(workos).toBeInstanceOf(WorkOS);
+ });
+
+ describe('with custom environment variables', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.resetModules();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('uses custom API hostname when provided', async () => {
+ process.env.WORKOS_API_HOSTNAME = 'custom.workos.com';
+ const { workos: customWorkos } = await import('../src/workos.js');
+
+ expect(customWorkos.options.apiHostname).toEqual('custom.workos.com');
+ });
+
+ it('uses custom HTTPS setting when provided', async () => {
+ process.env.WORKOS_API_HTTPS = 'false';
+ const { workos: customWorkos } = await import('../src/workos.js');
+
+ expect(customWorkos.options.https).toEqual(false);
+ });
+
+ it('uses custom port when provided', async () => {
+ process.env.WORKOS_API_PORT = '8080';
+ const { workos: customWorkos } = await import('../src/workos.js');
+
+ expect(customWorkos.options.port).toEqual(8080);
+ });
+ });
+});
diff --git a/jest.config.ts b/jest.config.ts
index a5a0f5e..7629368 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -53,6 +53,15 @@ const config: Config = {
// Optionally, add these for better TypeScript support
extensionsToTreatAsEsm: ['.ts'],
+
+ coverageThreshold: {
+ global: {
+ branches: 100,
+ functions: 100,
+ lines: 100,
+ statements: 100,
+ },
+ },
};
export default config;
diff --git a/src/authkit-callback-route.ts b/src/authkit-callback-route.ts
index 9747729..6817114 100644
--- a/src/authkit-callback-route.ts
+++ b/src/authkit-callback-route.ts
@@ -1,8 +1,9 @@
-import { NextRequest, NextResponse } from 'next/server';
+import { NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import { workos } from './workos.js';
import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME } from './env-variables.js';
import { encryptSession } from './session.js';
+import { errorResponseWithFallback, redirectWithFallback } from './utils.js';
import { getCookieOptions } from './cookie.js';
import { HandleAuthOptions } from './interfaces.js';
@@ -58,14 +59,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
// Fall back to standard Response if NextResponse is not available.
// This is to support Next.js 13.
- const response = NextResponse?.redirect
- ? NextResponse.redirect(url)
- : new Response(null, {
- status: 302,
- headers: {
- Location: url.toString(),
- },
- });
+ const response = redirectWithFallback(url.toString());
if (!accessToken || !refreshToken) throw new Error('response is missing tokens');
@@ -93,20 +87,11 @@ export function handleAuth(options: HandleAuthOptions = {}) {
};
function errorResponse() {
- const errorBody = {
+ return errorResponseWithFallback({
error: {
message: 'Something went wrong',
description: "Couldn't sign in. If you are not sure what happened, please contact your organization admin.",
},
- };
-
- // Use NextResponse if available, fallback to standard Response
- // This is to support Next.js 13.
- return NextResponse?.json
- ? NextResponse.json(errorBody, { status: 500 })
- : new Response(JSON.stringify(errorBody), {
- status: 500,
- headers: { 'Content-Type': 'application/json' },
- });
+ });
}
}
diff --git a/src/session.ts b/src/session.ts
index b482667..4bc6945 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -12,6 +12,7 @@ import { getAuthorizationUrl } from './get-authorization-url.js';
import { AccessToken, AuthkitMiddlewareAuth, NoUserInfo, Session, UserInfo } from './interfaces.js';
import { parse, tokensToRegexp } from 'path-to-regexp';
+import { redirectWithFallback } from './utils.js';
const sessionHeaderName = 'x-workos-session';
const middlewareHeaderName = 'x-workos-middleware';
@@ -90,7 +91,7 @@ async function updateSession(
const redirectTo = await getAuthorizationUrl({
returnPathname: getReturnPathname(request.url),
- redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
+ redirectUri: redirectUri,
screenHint: getScreenHint(signUpPaths, request.nextUrl.pathname),
});
@@ -235,6 +236,7 @@ function getMiddlewareAuthPathRegex(pathGlob: string) {
return new RegExp(regex);
} catch (err) {
+ console.log('err', err);
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Error parsing routes for middleware auth. Reason: ${message}`);
@@ -390,12 +392,4 @@ function getScreenHint(signUpPaths: string[] | undefined, pathname: string) {
return screenHintPaths.length > 0 ? 'sign-up' : 'sign-in';
}
-function redirectWithFallback(redirectUri: string) {
- // Fall back to standard Response if NextResponse is not available.
- // This is to support Next.js 13.
- return NextResponse?.redirect
- ? NextResponse.redirect(redirectUri)
- : new Response(null, { status: 307, headers: { Location: redirectUri } });
-}
-
export { encryptSession, withAuth, refreshSession, terminateSession, updateSession, getSession };
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..d3c1a89
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server';
+
+export function redirectWithFallback(redirectUri: string) {
+ // Fall back to standard Response if NextResponse is not available.
+ // This is to support Next.js 13.
+ return NextResponse?.redirect
+ ? NextResponse.redirect(redirectUri)
+ : new Response(null, { status: 307, headers: { Location: redirectUri } });
+}
+
+export function errorResponseWithFallback(errorBody: { error: { message: string; description: string } }) {
+ // Fall back to standard Response if NextResponse is not available.
+ // This is to support Next.js 13.
+ return NextResponse?.json
+ ? NextResponse.json(errorBody, { status: 500 })
+ : new Response(JSON.stringify(errorBody), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
diff --git a/tsconfig.json b/tsconfig.json
index 0a07773..8595c61 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,9 @@
"outDir": "./dist/esm",
"module": "ES2020",
"moduleResolution": "node",
- "allowSyntheticDefaultImports": true
+ "allowSyntheticDefaultImports": true,
+ "declarationDir": "./dist/types"
},
"exclude": ["**/*.spec.ts", "**/*.spec.tsx", "jest.config.ts", "jest.setup.ts"]
}
+