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"] } +