Skip to content
This repository has been archived by the owner on Jul 28, 2024. It is now read-only.

Commit

Permalink
Add Tests (#19)
Browse files Browse the repository at this point in the history
* fix server tests

* shared tests

* server tests

* cleanup
  • Loading branch information
asafshen authored Feb 21, 2024
1 parent c25db54 commit 00aaf0c
Show file tree
Hide file tree
Showing 16 changed files with 435 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.json"
"project": "./tsconfig.eslint.json"
},
"plugins": [
"react",
Expand Down
9 changes: 8 additions & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)/)']
};
5 changes: 5 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -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();
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions scripts/gitleaks/.gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
16 changes: 5 additions & 11 deletions src/server/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
6 changes: 3 additions & 3 deletions src/server/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/server/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions test/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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', () => {
Expand Down
119 changes: 119 additions & 0 deletions test/server/authMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
cookies?: Record<string, string>;
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
});
});
});
81 changes: 81 additions & 0 deletions test/server/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
});
Loading

0 comments on commit 00aaf0c

Please sign in to comment.