Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions __tests__/unit-tests/routes/public/public.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { status } from '@const/enumTypes';
import { Form } from '@models';
import publicRoutes from '@routes/public';
import express, { NextFunction, Request, Response } from 'express';
import mongoose from 'mongoose';
import supertest from 'supertest';
import { DatabaseHelpers } from '../../../helpers/database-helpers';

jest.mock('@services/logger.service');

/**
* Build a minimal express app mounting the public routes, with a stubbed
* translation function since the real i18next middleware is not loaded here.
*
* @returns Express application
*/
const buildApp = () => {
const app = express();
app.use((req: Request, res: Response, next: NextFunction) => {
(req as any).t = (key: string) => key;
next();
});
app.use('/public', publicRoutes);
return app;
};

let databaseHelpers: DatabaseHelpers;
let request: supertest.SuperTest<supertest.Test>;
let publicForm: Form;
let privateForm: Form;

describe('Public routes', () => {
beforeAll(async () => {
databaseHelpers = new DatabaseHelpers();
await databaseHelpers.connect();
request = supertest(buildApp());
publicForm = await Form.create({
name: 'Public form',
graphQLTypeName: 'PublicForm',
status: status.active,
isPublic: true,
structure: { pages: [] },
fields: [{ name: 'description', type: 'text' }],
permissions: {
canSee: [new mongoose.Types.ObjectId()],
},
});
privateForm = await Form.create({
name: 'Private form',
graphQLTypeName: 'PrivateForm',
status: status.active,
structure: { pages: [] },
fields: [],
});
});

afterAll(async () => {
await databaseHelpers.disconnect();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('GET /public/forms/:id', () => {
it('should return a form marked as public', async () => {
const response = await request.get(`/public/forms/${publicForm.id}`);

expect(response.status).toBe(200);
expect(response.body._id).toEqual(publicForm.id);
expect(response.body.name).toEqual('Public form');
expect(response.body.status).toEqual(status.active);
expect(response.body.structure).toEqual({ pages: [] });
expect(response.body.fields).toEqual([
{ name: 'description', type: 'text' },
]);
});

it('should not expose fields outside the public whitelist', async () => {
const response = await request.get(`/public/forms/${publicForm.id}`);

expect(response.status).toBe(200);
expect(response.body.permissions).toBeUndefined();
expect(response.body.isPublic).toBeUndefined();
expect(response.body.graphQLTypeName).toBeUndefined();
});

it('should return 404 for a form not marked as public', async () => {
const response = await request.get(`/public/forms/${privateForm.id}`);

expect(response.status).toBe(404);
});

it('should return 404 for a non-existing form', async () => {
const response = await request.get(
`/public/forms/${new mongoose.Types.ObjectId()}`
);

expect(response.status).toBe(404);
});

it('should return 404 for an invalid form id', async () => {
const response = await request.get('/public/forms/not-an-object-id');

expect(response.status).toBe(404);
});

it('should return 500 if the database query fails', async () => {
jest.spyOn(Form, 'findOne').mockImplementationOnce(() => {
throw new Error('Database error');
});

const response = await request.get(`/public/forms/${publicForm.id}`);

expect(response.status).toBe(500);
});
});
});
204 changes: 204 additions & 0 deletions __tests__/unit-tests/schema/mutation/addRecord.mutation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { Form, Record } from '@models';
import addRecord, {
AddRecordArgs,
} from '@schema/mutation/addRecord.mutation';
import { Types } from 'mongoose';
import { DatabaseHelpers } from '../../../helpers/database-helpers';
import { GraphQLError } from 'graphql';
import { Context } from '@server/apollo/context';
import { logger } from '@services/logger.service';
import extendAbilityForRecords from '@security/extendAbilityForRecords';
import { verifyTurnstileToken } from '@utils/captcha';
import { getNextId } from '@utils/form';

jest.mock('@services/logger.service');

// Mock the extendAbilityForRecords function
jest.mock('@security/extendAbilityForRecords', () => ({
__esModule: true,
default: jest.fn(),
}));

// Mock the captcha verification, so no call is made to Cloudflare
jest.mock('@utils/captcha', () => ({
__esModule: true,
verifyTurnstileToken: jest.fn(),
}));

// Mock getNextId only, as it relies on Redis
jest.mock('@utils/form', () => ({
...jest.requireActual('@utils/form'),
getNextId: jest.fn(),
}));

describe('addRecord Resolver', () => {
let context: Context;
let args: AddRecordArgs;
let databaseHelpers: DatabaseHelpers;
let publicForm: Form;
let privateForm: Form;
let nextIdCounter = 0;

beforeAll(async () => {
databaseHelpers = new DatabaseHelpers();
await databaseHelpers.connect();
publicForm = await Form.create({
name: 'Public form',
graphQLTypeName: 'PublicForm',
isPublic: true,
fields: [{ name: 'description', type: 'text' }],
});
privateForm = await Form.create({
name: 'Private form',
graphQLTypeName: 'PrivateForm',
fields: [{ name: 'description', type: 'text' }],
});
});

afterAll(async () => {
await databaseHelpers.disconnect();
});

beforeEach(() => {
jest.clearAllMocks();
context = {
user: {
_id: new Types.ObjectId(),
name: 'Test User',
username: 'test@user.com',
roles: [{ _id: new Types.ObjectId() }],
positionAttributes: [],
ability: { can: jest.fn().mockReturnValue(true) },
},
i18next: { t: jest.fn((key: string) => key) },
timeZone: 'UTC',
} as unknown as Context;

args = {
form: privateForm.id,
data: { description: 'test record' },
};

(extendAbilityForRecords as jest.Mock).mockResolvedValue({
can: jest.fn().mockReturnValue(true),
cannot: jest.fn().mockReturnValue(false),
});
(verifyTurnstileToken as jest.Mock).mockResolvedValue(true);
// Records have a unique index on incrementalId, so each call must
// return a different id, as the real implementation does
(getNextId as jest.Mock).mockImplementation(async () => {
nextIdCounter += 1;
return `2026-P${String(nextIdCounter).padStart(8, '0')}`;
});
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('Authenticated user', () => {
it('should create a record if the user has permission', async () => {
const record = await addRecord.resolve(null, args, context);
expect(record).toBeInstanceOf(Record);
expect(record.createdBy.user).toEqual(context.user._id);
expect(record._createdBy.user.username).toEqual('test@user.com');
expect(record.incrementalId).toMatch(/^2026-P\d{8}$/);
expect(record.data.description).toEqual('test record');
});

it('should not require a captcha token', async () => {
await addRecord.resolve(null, args, context);
expect(verifyTurnstileToken).not.toHaveBeenCalled();
});

it('should throw an error if the user does not have permission', async () => {
(extendAbilityForRecords as jest.Mock).mockResolvedValue({
can: jest.fn().mockReturnValue(false),
cannot: jest.fn().mockReturnValue(true),
});
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.permissionNotGranted'
);
});

it('should throw an error if the form is not found', async () => {
args.form = new Types.ObjectId().toHexString();
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.dataNotFound'
);
});
});

describe('Unauthenticated user', () => {
beforeEach(() => {
context = { ...context, user: null } as unknown as Context;
args = {
form: publicForm.id,
data: { description: 'public record' },
captchaToken: 'captcha-token',
};
});

it('should throw an error if the form is not public', async () => {
args.form = privateForm.id;
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.userNotLogged'
);
expect(verifyTurnstileToken).not.toHaveBeenCalled();
});

it('should throw an error if no captcha token is provided', async () => {
args.captchaToken = undefined;
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.invalidCaptcha'
);
expect(verifyTurnstileToken).not.toHaveBeenCalled();
});

it('should throw an error if the captcha token is invalid', async () => {
(verifyTurnstileToken as jest.Mock).mockResolvedValue(false);
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(verifyTurnstileToken).toHaveBeenCalledWith('captcha-token');
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.invalidCaptcha'
);
});

it('should create a record on a public form with a valid captcha token', async () => {
const record = await addRecord.resolve(null, args, context);
expect(record).toBeInstanceOf(Record);
expect(verifyTurnstileToken).toHaveBeenCalledWith('captcha-token');
expect(record.createdBy?.user).toBeUndefined();
expect(record._createdBy?.user).toBeUndefined();
expect(record.data.description).toEqual('public record');
});

it('should skip the ability check', async () => {
await addRecord.resolve(null, args, context);
expect(extendAbilityForRecords).not.toHaveBeenCalled();
});
});

describe('Error Handling', () => {
it('should log the error and throw GraphQLError on unexpected errors', async () => {
jest
.spyOn(Record.prototype, 'save')
.mockRejectedValue(new Error('unexpected error'));
const result = addRecord.resolve(null, args, context);
await expect(result).rejects.toThrow(GraphQLError);
expect(logger.error).toHaveBeenCalled();
expect(context.i18next.t).toHaveBeenCalledWith(
'common.errors.internalServerError'
);
});
});
});
70 changes: 70 additions & 0 deletions __tests__/unit-tests/utils/captcha/verifyTurnstileToken.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import axios from 'axios';
import config from 'config';
import { verifyTurnstileToken } from '@utils/captcha';
import { logger } from '@services/logger.service';

jest.mock('axios');
jest.mock('@services/logger.service');

const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('verifyTurnstileToken', () => {
let configGetSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
configGetSpy = jest.spyOn(config, 'get').mockReturnValue('test-secret');
});

afterEach(() => {
configGetSpy.mockRestore();
});

it('should return false and skip the API call if the secret is not configured', async () => {
configGetSpy.mockReturnValue('');
const result = await verifyTurnstileToken('some-token');
expect(result).toBe(false);
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
});

it('should return true when the verification succeeds', async () => {
mockedAxios.post.mockResolvedValue({ data: { success: true } });
const result = await verifyTurnstileToken('valid-token');
expect(result).toBe(true);
expect(mockedAxios.post).toHaveBeenCalledWith(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{ secret: 'test-secret', response: 'valid-token' },
expect.objectContaining({ timeout: expect.any(Number) })
);
});

it('should include the remote ip in the verification request when provided', async () => {
mockedAxios.post.mockResolvedValue({ data: { success: true } });
await verifyTurnstileToken('valid-token', '1.2.3.4');
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.any(String),
{
secret: 'test-secret',
response: 'valid-token',
remoteip: '1.2.3.4',
},
expect.anything()
);
});

it('should return false when the verification fails', async () => {
mockedAxios.post.mockResolvedValue({
data: { success: false, 'error-codes': ['invalid-input-response'] },
});
const result = await verifyTurnstileToken('invalid-token');
expect(result).toBe(false);
});

it('should return false when the verification request throws', async () => {
mockedAxios.post.mockRejectedValue(new Error('network error'));
const result = await verifyTurnstileToken('valid-token');
expect(result).toBe(false);
expect(logger.error).toHaveBeenCalled();
});
});
Loading
Loading