From 00aaf0ca670de261743b3f73a1c2a01d1ef9d029 Mon Sep 17 00:00:00 2001 From: Asaf Shen Date: Wed, 21 Feb 2024 07:14:45 +0200 Subject: [PATCH] Add Tests (#19) * fix server tests * shared tests * server tests * cleanup --- .eslintrc | 2 +- jest.config.mjs | 9 ++- jest.setup.js | 5 ++ package.json | 2 +- scripts/gitleaks/.gitleaks.toml | 1 + src/server/authMiddleware.ts | 16 ++-- src/server/constants.ts | 5 ++ src/server/sdk.ts | 6 +- src/server/session.ts | 6 +- test/index.test.ts | 9 ++- test/server/authMiddleware.test.ts | 119 +++++++++++++++++++++++++++++ test/server/sdk.test.ts | 81 ++++++++++++++++++++ test/server/session.test.ts | 69 +++++++++++++++++ test/shared/AuthProvider.test.tsx | 35 +++++++++ test/shared/DescopeFlows.test.tsx | 82 ++++++++++++++++++++ tsconfig.eslint.json | 11 +++ 16 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 test/server/authMiddleware.test.ts create mode 100644 test/server/sdk.test.ts create mode 100644 test/server/session.test.ts create mode 100644 test/shared/AuthProvider.test.tsx create mode 100644 test/shared/DescopeFlows.test.tsx create mode 100644 tsconfig.eslint.json diff --git a/.eslintrc b/.eslintrc index 95fe193..fbb02ab 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,7 +29,7 @@ }, "ecmaVersion": "latest", "sourceType": "module", - "project": "./tsconfig.json" + "project": "./tsconfig.eslint.json" }, "plugins": [ "react", diff --git a/jest.config.mjs b/jest.config.mjs index 19e4741..38f58d0 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,5 +11,12 @@ export default { { configFile: './babel.config.cjs' } ] }, - testEnvironment: 'jsdom' + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json' + }, + BUILD_VERSION: 'one.two.three' + }, + testEnvironment: 'jsdom', + transformIgnorePatterns: ['node_modules/(?!(jose)/)'] }; diff --git a/jest.setup.js b/jest.setup.js index d36d111..44a5825 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1 +1,6 @@ +// Required for node-js sdk dependency (jose) +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextDecoder, TextEncoder }); + +// Mock fetch require('jest-fetch-mock').enableMocks(); diff --git a/package.json b/package.json index 41165ac..6891367 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "format-check": "prettier . --check --ignore-path .gitignore", "leaks": "bash ./scripts/gitleaks/gitleaks.sh", "lint": "npm run lint-check -- --fix", - "lint-check": "eslint '+(src)/**/*.+(ts|tsx)'", + "lint-check": "eslint '+(src|test)/**/*.+(ts|tsx)'", "prepare": "husky install", "prepublishOnly": "npm run build", "start": "npm run build && (cd examples/app-router && npm run dev)", diff --git a/scripts/gitleaks/.gitleaks.toml b/scripts/gitleaks/.gitleaks.toml index 1c5137f..7dab433 100644 --- a/scripts/gitleaks/.gitleaks.toml +++ b/scripts/gitleaks/.gitleaks.toml @@ -650,4 +650,5 @@ paths = [ '''gitleaks.toml''', '''(.*?)(jpg|gif|doc|pdf|bin|svg|socket|js|ts|json)$''', "node_modules/", + ".*/\\.next/.*", # This line is added to ignore the .next directory ] diff --git a/src/server/authMiddleware.ts b/src/server/authMiddleware.ts index 29addb4..bd4ef90 100644 --- a/src/server/authMiddleware.ts +++ b/src/server/authMiddleware.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import descopeSdk from '@descope/node-sdk'; import type { AuthenticationInfo } from '@descope/node-sdk'; -import { DESCOPE_SESSION_HEADER } from './constants'; +import { DEFAULT_PUBLIC_ROUTES, DESCOPE_SESSION_HEADER } from './constants'; import { getGlobalSdk } from './sdk'; type MiddlewareOptions = { @@ -34,13 +34,8 @@ const getSessionJwt = (req: NextRequest): string | undefined => { return undefined; }; -const defaultPublicRoutes = { - signIn: process.env.SIGN_IN_ROUTE || '/sign-in', - signUp: process.env.SIGN_UP_ROUTE || '/sign-up' -}; - const isPublicRoute = (req: NextRequest, options: MiddlewareOptions) => { - const isDefaultPublicRoute = Object.values(defaultPublicRoutes).includes( + const isDefaultPublicRoute = Object.values(DEFAULT_PUBLIC_ROUTES).includes( req.nextUrl.pathname ); const isPublic = options.publicRoutes?.includes(req.nextUrl.pathname); @@ -82,11 +77,10 @@ const createAuthMiddleware = } catch (err) { console.debug('Auth middleware, Failed to validate JWT', err); if (!isPublicRoute(req, options)) { - const defaultRedirectUrl = - options.redirectUrl || defaultPublicRoutes.signIn; + const redirectUrl = options.redirectUrl || DEFAULT_PUBLIC_ROUTES.signIn; const url = req.nextUrl.clone(); - url.pathname = defaultRedirectUrl; - console.debug(`Auth middleware, Redirecting to ${url}`); + url.pathname = redirectUrl; + console.debug(`Auth middleware, Redirecting to ${redirectUrl}`); return NextResponse.redirect(url); } } diff --git a/src/server/constants.ts b/src/server/constants.ts index a5e34c1..c22678d 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -8,3 +8,8 @@ export const baseHeaders = { 'x-descope-sdk-name': 'nextjs', 'x-descope-sdk-version': BUILD_VERSION }; + +export const DEFAULT_PUBLIC_ROUTES = { + signIn: process.env.SIGN_IN_ROUTE || '/sign-in', + signUp: process.env.SIGN_UP_ROUTE || '/sign-up' +}; diff --git a/src/server/sdk.ts b/src/server/sdk.ts index 2f112f2..e377fdc 100644 --- a/src/server/sdk.ts +++ b/src/server/sdk.ts @@ -11,9 +11,9 @@ let globalSdk: Sdk; export const createSdk = (config?: CreateSdkParams): Sdk => descopeSdk({ ...config, - projectId: config.projectId || process.env.DESCOPE_PROJECT_ID, - managementKey: config.managementKey || process.env.DESCOPE_MANAGEMENT_KEY, - baseUrl: config.baseUrl || process.env.DESCOPE_BASE_URL, + projectId: config?.projectId || process.env.DESCOPE_PROJECT_ID, + managementKey: config?.managementKey || process.env.DESCOPE_MANAGEMENT_KEY, + baseUrl: config?.baseUrl || process.env.DESCOPE_BASE_URL, baseHeaders: { ...config?.baseHeaders, ...baseHeaders diff --git a/src/server/session.ts b/src/server/session.ts index 3fc4a49..8b763b0 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -20,8 +20,10 @@ const extractSession = ( }; // returns the session token if it exists in the headers // This function require middleware -export const session = (): AuthenticationInfo | undefined => - extractSession(headers()?.get(DESCOPE_SESSION_HEADER)); +export const session = (): AuthenticationInfo | undefined => { + const sessionHeader = headers()?.get(DESCOPE_SESSION_HEADER); + return extractSession(sessionHeader); +}; // returns the session token if it exists in the request headers // This function require middleware diff --git a/test/index.test.ts b/test/index.test.ts index a2dd4e2..ad6bf12 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-namespace */ import * as clientIndex from '../src/client/index'; import * as serverIndex from '../src/server/index'; import * as sharedIndex from '../src/index'; @@ -16,10 +17,10 @@ describe('index', () => { it('should import the correct things from server', () => { // Need to fix babel/jest to get this working - // expect(serverIndex).toHaveProperty('authMiddleware'); - // expect(serverIndex).toHaveProperty('createSdk'); - // expect(serverIndex).toHaveProperty('session'); - // expect(serverIndex).toHaveProperty('getSession'); + expect(serverIndex).toHaveProperty('authMiddleware'); + expect(serverIndex).toHaveProperty('createSdk'); + expect(serverIndex).toHaveProperty('session'); + expect(serverIndex).toHaveProperty('getSession'); }); it('should import the correct things from shared', () => { diff --git a/test/server/authMiddleware.test.ts b/test/server/authMiddleware.test.ts new file mode 100644 index 0000000..67ccd95 --- /dev/null +++ b/test/server/authMiddleware.test.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import authMiddleware from '../../src/server/authMiddleware'; +import { DEFAULT_PUBLIC_ROUTES } from '../../src/server/constants'; + +const mockValidateJwt = jest.fn(); +jest.mock('@descope/node-sdk', () => + jest.fn(() => ({ + validateJwt: mockValidateJwt + })) +); + +jest.mock('next/server', () => ({ + NextResponse: { + redirect: jest.fn(), + next: jest.fn() + } +})); + +// Utility function to create a mock NextRequest +const createMockNextRequest = ( + options: { + headers?: Record; + cookies?: Record; + pathname?: string; + } = {} +) => + ({ + headers: { + get: (name: string) => options.headers?.[name] + }, + cookies: { + get: (name: string) => ({ value: options.cookies?.[name] }) + }, + nextUrl: { + pathname: options.pathname || '/', + clone: jest.fn(() => ({ pathname: options.pathname || '/' })) + } + }) as unknown as NextRequest; + +describe('authMiddleware', () => { + beforeEach(() => { + // Set process.env.DESCOPE_PROJECT_ID to avoid errors + process.env.DESCOPE_PROJECT_ID = 'project1'; + (NextResponse.redirect as jest.Mock).mockImplementation((url) => url); + (NextResponse.next as jest.Mock).mockImplementation(() => ({ + headers: { set: jest.fn() }, + request: jest.fn() + })); + }); + + afterEach(() => { + jest.resetAllMocks(); + (NextResponse.redirect as jest.Mock).mockReset(); + (NextResponse.next as jest.Mock).mockReset(); + mockValidateJwt?.mockReset(); + }); + + it('redirects unauthenticated users for non-public routes', async () => { + mockValidateJwt.mockRejectedValue(new Error('Invalid JWT')); + const middleware = authMiddleware(); + const mockReq = createMockNextRequest({ pathname: '/private' }); + + const response = await middleware(mockReq); + expect(NextResponse.redirect).toHaveBeenCalledWith(expect.anything()); + expect(response).toEqual({ + pathname: DEFAULT_PUBLIC_ROUTES.signIn + }); + }); + + it('allows unauthenticated users for public routes', async () => { + mockValidateJwt.mockRejectedValue(new Error('Invalid JWT')); + const middleware = authMiddleware({ + publicRoutes: ['/sign-in', '/sign-up'] + }); + const mockReq = createMockNextRequest({ pathname: '/sign-in' }); + + await middleware(mockReq); + // Expect the middleware not to redirect + expect(NextResponse.redirect).not.toHaveBeenCalled(); + }); + + it('allows authenticated users for non-public routes and adds proper headers', async () => { + // Mock validateJwt to simulate an authenticated user + const authInfo = { + jwt: 'validJwt', + token: { iss: 'project-1', sub: 'user-123' } + }; + mockValidateJwt.mockImplementation(() => authInfo); + + const middleware = authMiddleware(); + const mockReq = createMockNextRequest({ + pathname: '/private', + headers: { Authorization: 'Bearer validJwt' } + }); + + await middleware(mockReq); + // Expect no redirect and check if response contains session headers + expect(NextResponse.redirect).not.toHaveBeenCalled(); + expect(NextResponse.next).toHaveBeenCalled(); + + const headersArg = (NextResponse.next as any as jest.Mock).mock.lastCall[0] + .request.headers; + expect(headersArg.get('x-descope-session')).toEqual( + Buffer.from(JSON.stringify(authInfo)).toString('base64') + ); + }); + + it('blocks unauthenticated users and redirects to custom URL', async () => { + mockValidateJwt.mockRejectedValue(new Error('Invalid JWT')); + const customRedirectUrl = '/custom-sign-in'; + const middleware = authMiddleware({ redirectUrl: customRedirectUrl }); + const mockReq = createMockNextRequest({ pathname: '/private' }); + + await middleware(mockReq); + expect(NextResponse.redirect).toHaveBeenCalledWith({ + pathname: customRedirectUrl + }); + }); +}); diff --git a/test/server/sdk.test.ts b/test/server/sdk.test.ts new file mode 100644 index 0000000..0903641 --- /dev/null +++ b/test/server/sdk.test.ts @@ -0,0 +1,81 @@ +import descopeSdk from '@descope/node-sdk'; +import { baseHeaders } from '../../src/server/constants'; +import { createSdk, getGlobalSdk } from '../../src/server/sdk'; + +jest.mock('@descope/node-sdk', () => jest.fn()); + +describe('sdk', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetModules(); + }); + + describe('createSdk', () => { + it('should create a new sdk with parameters', () => { + const config = { projectId: 'project1', managementKey: 'key1' }; + createSdk(config); + + expect(descopeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project1', + managementKey: 'key1', + baseHeaders: expect.objectContaining(baseHeaders) + }) + ); + }); + + it('should create a new sdk with env variables', () => { + process.env.DESCOPE_PROJECT_ID = 'envProjectId'; + process.env.DESCOPE_MANAGEMENT_KEY = 'envManagementKey'; + process.env.DESCOPE_BASE_URL = 'envBaseUrl'; + + createSdk(); + + expect(descopeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'envProjectId', + managementKey: 'envManagementKey', + baseUrl: 'envBaseUrl', + baseHeaders: expect.any(Object) + }) + ); + + // Clean up environment variables to avoid side effects + delete process.env.DESCOPE_PROJECT_ID; + delete process.env.DESCOPE_MANAGEMENT_KEY; + delete process.env.DESCOPE_BASE_URL; + }); + }); + + describe('getGlobalSdk', () => { + it('should create a new sdk if one does not exist', () => { + const config = { projectId: 'project1' }; + getGlobalSdk(config); + + expect(descopeSdk).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project1' + }) + ); + }); + + it('should return the existing sdk', () => { + const sdk1 = getGlobalSdk({ projectId: 'project1' }); + const sdk2 = getGlobalSdk({ projectId: 'project1' }); + + expect(sdk1).toBe(sdk2); // Verify that the same SDK instance is returned + }); + + it("should throw an error if no projectId is provided and it's not in env", () => { + // environment variable is not set and no projectId is provided + delete process.env.DESCOPE_PROJECT_ID; + + expect(() => getGlobalSdk()).toThrow( + 'Descope project ID is required to create the SDK' + ); + }); + }); +}); diff --git a/test/server/session.test.ts b/test/server/session.test.ts new file mode 100644 index 0000000..8e14257 --- /dev/null +++ b/test/server/session.test.ts @@ -0,0 +1,69 @@ +import { headers } from 'next/headers'; +import { session, getSession } from '../../src/server/session'; + +jest.mock('next/headers', () => ({ + headers: jest.fn() +})); + +describe('session utilities', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('session', () => { + it('should extract and return session information if present', () => { + (headers as jest.Mock).mockImplementation( + () => + new Map([ + [ + 'x-descope-session', + Buffer.from(JSON.stringify({ user: 'testUser' })).toString( + 'base64' + ) + ] + ]) + ); + const result = session(); + expect(result).toEqual({ user: 'testUser' }); + }); + + it('should return undefined if session header is missing', () => { + (headers as jest.Mock).mockImplementation(() => new Map()); + jest.mock('next/headers', () => ({ + headers: jest.fn().mockImplementation(() => new Map()) + })); + const result = session(); + expect(result).toBeUndefined(); + }); + }); + + describe('getSession', () => { + it('should extract and return session information if present in request headers', () => { + const mockReq = { + headers: { + 'x-descope-session': Buffer.from( + JSON.stringify({ user: 'testUser' }) + ).toString('base64') + } + }; + const result = getSession(mockReq as any); + expect(result).toEqual({ user: 'testUser' }); + }); + + it('should return undefined if session header is missing in request', () => { + const mockReq = { headers: {} }; + const result = getSession(mockReq as any); + expect(result).toBeUndefined(); + }); + + it('should return undefined if session header is malformed', () => { + const mockReq = { + headers: { + 'x-descope-session': 'malformedBase64' + } + }; + const result = getSession(mockReq as any); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/test/shared/AuthProvider.test.tsx b/test/shared/AuthProvider.test.tsx new file mode 100644 index 0000000..82f0ad4 --- /dev/null +++ b/test/shared/AuthProvider.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { AuthProvider as AuthProviderComp } from '@descope/react-sdk'; +import AuthProvider from '../../src/shared/AuthProvider'; + +jest.mock('@descope/react-sdk', () => ({ + AuthProvider: jest.fn() +})); + +describe('AuthProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render and pass sessionTokenViaCookie as true by default', () => { + render(); + expect(AuthProviderComp).toHaveBeenCalledWith( + expect.objectContaining({ + sessionTokenViaCookie: true + }), + expect.anything() // This accounts for the second argument to a component function, which is the ref in class components + ); + }); + + it('should allow sessionTokenViaCookie to be overridden to false', () => { + render(); + expect(AuthProviderComp).toHaveBeenCalledWith( + expect.objectContaining({ + sessionTokenViaCookie: false + }), + expect.anything() + ); + }); +}); diff --git a/test/shared/DescopeFlows.test.tsx b/test/shared/DescopeFlows.test.tsx new file mode 100644 index 0000000..b3a489b --- /dev/null +++ b/test/shared/DescopeFlows.test.tsx @@ -0,0 +1,82 @@ +/* eslint-disable testing-library/no-node-access */ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AuthProvider from '../../src/shared/AuthProvider'; +import { Descope } from '../../src/shared/DescopeFlows'; + +const mockPush = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(() => ({ push: mockPush })) +})); + +jest.mock('@descope/web-component', () => ({ default: {} })); + +describe('Descope', () => { + beforeEach(() => { + mockPush.mockClear(); + }); + + it('should trigger onSuccess callback and redirect on success event', async () => { + const onSuccessMock = jest.fn(); + render( + + + + ); + + // Wait for the Descope web component to be in the document + await waitFor(() => + expect(document.querySelector('descope-wc')).toBeInTheDocument() + ); + + // Simulate the success event + fireEvent( + (document as any).querySelector('descope-wc'), + new CustomEvent('success', { detail: { some: 'data' } }) + ); + + await waitFor(() => { + expect(onSuccessMock).toHaveBeenCalledWith( + expect.objectContaining({ detail: { some: 'data' } }) + ); + }); + + expect(mockPush).toHaveBeenCalledWith('/success-path'); + }); + + it('should trigger onError callback and redirect on error event', async () => { + const onErrorMock = jest.fn(); + render( + + + + ); + + // Wait for the Descope web component to be in the document + await waitFor(() => + expect(document.querySelector('descope-wc')).toBeInTheDocument() + ); + + // Simulate the error event + fireEvent( + (document as any).querySelector('descope-wc'), + new CustomEvent('error', { detail: { error: 'error-details' } }) + ); + + await waitFor(() => { + expect(onErrorMock).toHaveBeenCalledWith( + expect.objectContaining({ detail: { error: 'error-details' } }) + ); + }); + expect(mockPush).toHaveBeenCalledWith('/error-path'); + }); +}); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..b9ec5f1 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "examples/**/*.ts", + "examples/**/*.tsx" + ] +}