first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
INodeListSearchResult,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
ResourceMapperFields,
|
||||
NodeParameterValueType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import * as AdditionalData from '@/workflow-execute-additional-data';
|
||||
|
||||
import { createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { setupTestServer } from '../shared/utils';
|
||||
|
||||
describe('DynamicNodeParametersController', () => {
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const service = mockInstance(DynamicNodeParametersService);
|
||||
|
||||
const testServer = setupTestServer({ endpointGroups: ['dynamic-node-parameters'] });
|
||||
let ownerAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
const owner = await createOwner();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(AdditionalData, 'getBase').mockResolvedValue(additionalData);
|
||||
});
|
||||
|
||||
const commonRequestParams = {
|
||||
credentials: {},
|
||||
currentNodeParameters: {},
|
||||
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
|
||||
path: 'path',
|
||||
};
|
||||
|
||||
describe('POST /dynamic-node-parameters/options', () => {
|
||||
it('should take params via body', async () => {
|
||||
service.getOptionsViaMethodName.mockResolvedValue([]);
|
||||
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/options')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
methodName: 'testMethod',
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should take params with loadOptions', async () => {
|
||||
const expectedResult = [{ name: 'Test Option', value: 'test' }];
|
||||
service.getOptionsViaLoadOptions.mockResolvedValue(expectedResult);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/options')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
loadOptions: { type: 'test' },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: expectedResult });
|
||||
});
|
||||
|
||||
it('should return empty array when no method or loadOptions provided', async () => {
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/options')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /dynamic-node-parameters/resource-locator-results', () => {
|
||||
it('should return resource locator results', async () => {
|
||||
const expectedResult: INodeListSearchResult = { results: [] };
|
||||
service.getResourceLocatorResults.mockResolvedValue(expectedResult);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/resource-locator-results')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
methodName: 'testMethod',
|
||||
filter: 'testFilter',
|
||||
paginationToken: 'testToken',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: expectedResult });
|
||||
});
|
||||
|
||||
it('should handle resource locator results without pagination', async () => {
|
||||
const mockResults = mock<INodeListSearchResult>();
|
||||
service.getResourceLocatorResults.mockResolvedValue(mockResults);
|
||||
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/resource-locator-results')
|
||||
.send({
|
||||
methodName: 'testMethod',
|
||||
...commonRequestParams,
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return a 400 if methodName is not defined', async () => {
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/resource-locator-results')
|
||||
.send(commonRequestParams)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /dynamic-node-parameters/resource-mapper-fields', () => {
|
||||
it('should return resource mapper fields', async () => {
|
||||
const expectedResult: ResourceMapperFields = { fields: [] };
|
||||
service.getResourceMappingFields.mockResolvedValue(expectedResult);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/resource-mapper-fields')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
methodName: 'testMethod',
|
||||
loadOptions: 'testLoadOptions',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: expectedResult });
|
||||
});
|
||||
|
||||
it('should return a 400 if methodName is not defined', async () => {
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/resource-mapper-fields')
|
||||
.send(commonRequestParams)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /dynamic-node-parameters/local-resource-mapper-fields', () => {
|
||||
it('should return local resource mapper fields', async () => {
|
||||
const expectedResult: ResourceMapperFields = { fields: [] };
|
||||
service.getLocalResourceMappingFields.mockResolvedValue(expectedResult);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/local-resource-mapper-fields')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
methodName: 'testMethod',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: expectedResult });
|
||||
});
|
||||
|
||||
it('should return a 400 if methodName is not defined', async () => {
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/local-resource-mapper-fields')
|
||||
.send(commonRequestParams)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /dynamic-node-parameters/action-result', () => {
|
||||
it('should return action result with handler', async () => {
|
||||
const expectedResult: NodeParameterValueType = { test: true };
|
||||
service.getActionResult.mockResolvedValue(expectedResult);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/dynamic-node-parameters/action-result')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
handler: 'testHandler',
|
||||
payload: { someData: 'test' },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: expectedResult });
|
||||
});
|
||||
|
||||
it('should return a 400 if handler is not defined', async () => {
|
||||
await ownerAgent
|
||||
.post('/dynamic-node-parameters/action-result')
|
||||
.send({
|
||||
...commonRequestParams,
|
||||
payload: { someData: 'test' },
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { User } from '@n8n/db';
|
||||
import validator from 'validator';
|
||||
|
||||
import type { UserInvitationResult } from '../../shared/utils/users';
|
||||
|
||||
export function assertReturnedUserProps(user: User) {
|
||||
expect(validator.isUUID(user.id)).toBe(true);
|
||||
expect(user.email).toBeDefined();
|
||||
expect(user.personalizationAnswers).toBeNull();
|
||||
expect(user.password).toBeUndefined();
|
||||
expect(user.isPending).toBe(false);
|
||||
}
|
||||
|
||||
export const assertStoredUserProps = (user: User) => {
|
||||
expect(user.firstName).toBeNull();
|
||||
expect(user.lastName).toBeNull();
|
||||
expect(user.personalizationAnswers).toBeNull();
|
||||
expect(user.password).toBeNull();
|
||||
expect(user.isPending).toBe(true);
|
||||
};
|
||||
|
||||
export const assertUserInviteResult = (data: UserInvitationResult) => {
|
||||
expect(validator.isUUID(data.user.id)).toBe(true);
|
||||
expect(data.user.inviteAcceptUrl).toBeUndefined();
|
||||
expect(data.user.email).toBeDefined();
|
||||
expect(data.user.emailSent).toBe(true);
|
||||
};
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
mockInstance,
|
||||
randomEmail,
|
||||
randomInvalidPassword,
|
||||
randomName,
|
||||
randomValidPassword,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import {
|
||||
GLOBAL_ADMIN_ROLE,
|
||||
GLOBAL_MEMBER_ROLE,
|
||||
ProjectRelationRepository,
|
||||
UserRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
import { Not } from '@n8n/typeorm';
|
||||
|
||||
import {
|
||||
assertReturnedUserProps,
|
||||
assertStoredUserProps,
|
||||
assertUserInviteResult,
|
||||
} from './assertions';
|
||||
import { createMember, createOwner, createUserShell } from '../../shared/db/users';
|
||||
import * as utils from '../../shared/utils';
|
||||
import type { UserInvitationResult } from '../../shared/utils/users';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
describe('InvitationController', () => {
|
||||
const mailer = mockInstance(UserManagementMailer);
|
||||
const externalHooks = mockInstance(ExternalHooks);
|
||||
const eventService = mockInstance(EventService);
|
||||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['invitations'] });
|
||||
|
||||
let instanceOwner: User;
|
||||
let userRepository: UserRepository;
|
||||
let projectRelationRepository: ProjectRelationRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
userRepository = Container.get(UserRepository);
|
||||
projectRelationRepository = Container.get(ProjectRelationRepository);
|
||||
instanceOwner = await createOwner();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await userRepository.delete({ role: Not('global:owner') });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /invitations/:id/accept', () => {
|
||||
test('should fill out a member shell', async () => {
|
||||
const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE);
|
||||
|
||||
const memberProps = {
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
};
|
||||
|
||||
const response = await testServer.authlessAgent
|
||||
.post(`/invitations/${memberShell.id}/accept`)
|
||||
.send(memberProps)
|
||||
.expect(200);
|
||||
|
||||
const { data: returnedMember } = response.body;
|
||||
|
||||
assertReturnedUserProps(returnedMember);
|
||||
|
||||
expect(returnedMember.firstName).toBe(memberProps.firstName);
|
||||
expect(returnedMember.lastName).toBe(memberProps.lastName);
|
||||
expect(returnedMember.role).toBe('global:member');
|
||||
expect(utils.getAuthToken(response)).toBeDefined();
|
||||
|
||||
const storedMember = await userRepository.findOneByOrFail({ id: returnedMember.id });
|
||||
|
||||
expect(storedMember.firstName).toBe(memberProps.firstName);
|
||||
expect(storedMember.lastName).toBe(memberProps.lastName);
|
||||
expect(storedMember.password).not.toBe(memberProps.password);
|
||||
});
|
||||
|
||||
test('should fill out an admin shell', async () => {
|
||||
const adminShell = await createUserShell(GLOBAL_ADMIN_ROLE);
|
||||
|
||||
const memberProps = {
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
};
|
||||
|
||||
const response = await testServer.authlessAgent
|
||||
.post(`/invitations/${adminShell.id}/accept`)
|
||||
.send(memberProps)
|
||||
.expect(200);
|
||||
|
||||
const { data: returnedAdmin } = response.body;
|
||||
|
||||
assertReturnedUserProps(returnedAdmin);
|
||||
|
||||
expect(returnedAdmin.firstName).toBe(memberProps.firstName);
|
||||
expect(returnedAdmin.lastName).toBe(memberProps.lastName);
|
||||
expect(returnedAdmin.role).toBe('global:admin');
|
||||
expect(utils.getAuthToken(response)).toBeDefined();
|
||||
|
||||
const storedAdmin = await userRepository.findOneByOrFail({ id: returnedAdmin.id });
|
||||
|
||||
expect(storedAdmin.firstName).toBe(memberProps.firstName);
|
||||
expect(storedAdmin.lastName).toBe(memberProps.lastName);
|
||||
expect(storedAdmin.password).not.toBe(memberProps.password);
|
||||
});
|
||||
|
||||
test('should fail with invalid payloads', async () => {
|
||||
const memberShell = await userRepository.save({
|
||||
email: randomEmail(),
|
||||
role: { slug: 'global:member' },
|
||||
});
|
||||
|
||||
const invalidPaylods = [
|
||||
{
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
},
|
||||
{
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
},
|
||||
{
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
},
|
||||
{
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
},
|
||||
{
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
password: randomInvalidPassword(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of invalidPaylods) {
|
||||
await testServer.authlessAgent
|
||||
.post(`/invitations/${memberShell.id}/accept`)
|
||||
.send(payload)
|
||||
.expect(400);
|
||||
|
||||
const storedMemberShell = await userRepository.findOneByOrFail({
|
||||
email: memberShell.email,
|
||||
});
|
||||
|
||||
expect(storedMemberShell.firstName).toBeNull();
|
||||
expect(storedMemberShell.lastName).toBeNull();
|
||||
expect(storedMemberShell.password).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('should fail with already accepted invite', async () => {
|
||||
const member = await createMember();
|
||||
|
||||
const memberProps = {
|
||||
inviterId: instanceOwner.id,
|
||||
firstName: randomName(),
|
||||
lastName: randomName(),
|
||||
password: randomValidPassword(),
|
||||
};
|
||||
|
||||
await testServer.authlessAgent
|
||||
.post(`/invitations/${member.id}/accept`)
|
||||
.send(memberProps)
|
||||
.expect(400);
|
||||
|
||||
const storedMember = await userRepository.findOneByOrFail({
|
||||
email: member.email,
|
||||
});
|
||||
|
||||
expect(storedMember.firstName).not.toBe(memberProps.firstName);
|
||||
expect(storedMember.lastName).not.toBe(memberProps.lastName);
|
||||
expect(storedMember.password).not.toBe(memberProps.password);
|
||||
|
||||
const comparisonResult = await Container.get(PasswordUtility).compare(
|
||||
member.password!,
|
||||
storedMember.password,
|
||||
);
|
||||
|
||||
expect(comparisonResult).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /invitations', () => {
|
||||
type InvitationResponse = { body: { data: UserInvitationResult[] } };
|
||||
|
||||
test('should fail with invalid payloads', async () => {
|
||||
const invalidPayloads = [
|
||||
randomEmail(),
|
||||
[randomEmail()],
|
||||
{},
|
||||
[{ name: randomName() }],
|
||||
[{ email: randomName() }],
|
||||
];
|
||||
|
||||
for (const invalidPayload of invalidPayloads) {
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send(invalidPayload)
|
||||
.expect(400);
|
||||
|
||||
await expect(userRepository.count()).resolves.toBe(2); // DB unaffected
|
||||
}
|
||||
});
|
||||
|
||||
test('should return 200 on empty payload', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([])
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toStrictEqual([]);
|
||||
|
||||
await expect(userRepository.count()).resolves.toBe(2); // DB unaffected
|
||||
});
|
||||
|
||||
test('should return 200 if emailing is not set up', async () => {
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail() }]);
|
||||
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
|
||||
const { user } = response.body.data[0];
|
||||
|
||||
expect(user.inviteAcceptUrl).toBeDefined();
|
||||
expect(user).toHaveProperty('role', 'global:member');
|
||||
|
||||
const inviteUrl = new URL(user.inviteAcceptUrl);
|
||||
|
||||
expect(inviteUrl.searchParams.get('inviterId')).toBe(instanceOwner.id);
|
||||
expect(inviteUrl.searchParams.get('inviteeId')).toBe(user.id);
|
||||
});
|
||||
|
||||
test('should create member shell', async () => {
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
const response: InvitationResponse = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail() }])
|
||||
.expect(200);
|
||||
|
||||
const [result] = response.body.data;
|
||||
|
||||
const storedUser = await userRepository.findOneByOrFail({
|
||||
id: result.user.id,
|
||||
});
|
||||
|
||||
assertStoredUserProps(storedUser);
|
||||
});
|
||||
|
||||
test('should create personal project for shell account', async () => {
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
const response: InvitationResponse = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail() }])
|
||||
.expect(200);
|
||||
|
||||
const [result] = response.body.data;
|
||||
|
||||
const storedUser = await userRepository.findOneByOrFail({
|
||||
id: result.user.id,
|
||||
});
|
||||
|
||||
assertStoredUserProps(storedUser);
|
||||
|
||||
const projectRelation = await projectRelationRepository.findOneOrFail({
|
||||
where: {
|
||||
userId: storedUser.id,
|
||||
role: { slug: PROJECT_OWNER_ROLE_SLUG },
|
||||
project: {
|
||||
type: 'personal',
|
||||
},
|
||||
},
|
||||
relations: { project: true },
|
||||
});
|
||||
|
||||
expect(projectRelation).not.toBeUndefined();
|
||||
expect(projectRelation.project.name).toBe(storedUser.createPersonalProjectName());
|
||||
expect(projectRelation.project.type).toBe('personal');
|
||||
});
|
||||
|
||||
test('should create admin shell when advanced permissions is licensed', async () => {
|
||||
testServer.license.enable('feat:advancedPermissions');
|
||||
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
const response: InvitationResponse = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:admin' }])
|
||||
.expect(200);
|
||||
|
||||
const [result] = response.body.data;
|
||||
|
||||
const storedUser = await userRepository.findOneByOrFail({
|
||||
id: result.user.id,
|
||||
});
|
||||
|
||||
assertStoredUserProps(storedUser);
|
||||
});
|
||||
|
||||
test('should reinvite member when sharing is licensed', async () => {
|
||||
testServer.license.enable('feat:sharing');
|
||||
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:member' }]);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:member' }])
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
test('should reinvite admin when advanced permissions is licensed', async () => {
|
||||
testServer.license.enable('feat:advancedPermissions');
|
||||
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:admin' }]);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:admin' }])
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
test('should return 403 on creating admin shell when advanced permissions is unlicensed', async () => {
|
||||
testServer.license.disable('feat:advancedPermissions');
|
||||
|
||||
mailer.invite.mockResolvedValue({ emailSent: false });
|
||||
|
||||
await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail(), role: 'global:admin' }])
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('should email invites and create user shells, without inviting existing users', async () => {
|
||||
mailer.invite.mockResolvedValue({ emailSent: true });
|
||||
|
||||
const member = await createMember();
|
||||
const memberShell = await createUserShell(GLOBAL_MEMBER_ROLE);
|
||||
const newUserEmail = randomEmail();
|
||||
|
||||
const existingUserEmails = [member.email];
|
||||
const inviteeUserEmails = [memberShell.email, newUserEmail];
|
||||
const payload = inviteeUserEmails.concat(existingUserEmails).map((email) => ({ email }));
|
||||
|
||||
const response: InvitationResponse = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send(payload)
|
||||
.expect(200);
|
||||
|
||||
// invite results
|
||||
|
||||
const { data: results } = response.body;
|
||||
|
||||
for (const result of results) {
|
||||
assertUserInviteResult(result);
|
||||
|
||||
const storedUser = await Container.get(UserRepository).findOneByOrFail({
|
||||
id: result.user.id,
|
||||
});
|
||||
|
||||
assertStoredUserProps(storedUser);
|
||||
}
|
||||
|
||||
// external hooks
|
||||
|
||||
expect(externalHooks.run).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [externalHookName, externalHookArg] = externalHooks.run.mock.calls[0];
|
||||
|
||||
expect(externalHookName).toBe('user.invited');
|
||||
expect(externalHookArg?.[0]).toStrictEqual([newUserEmail]);
|
||||
|
||||
for (const [eventName, payload] of eventService.emit.mock.calls) {
|
||||
if (eventName === 'user-invited') {
|
||||
expect(payload).toEqual({
|
||||
user: expect.objectContaining({ id: expect.any(String) }),
|
||||
targetUserId: expect.arrayContaining([expect.any(String), expect.any(String)]),
|
||||
publicApi: false,
|
||||
emailSent: true,
|
||||
inviteeRole: 'global:member',
|
||||
});
|
||||
} else if (eventName === 'user-transactional-email-sent') {
|
||||
expect(payload).toEqual({
|
||||
userId: expect.any(String),
|
||||
messageType: 'New user invite',
|
||||
publicApi: false,
|
||||
});
|
||||
} else {
|
||||
fail(`Unexpected event name: ${eventName}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should return 200 and surface error when invite method throws error', async () => {
|
||||
const errorMsg = 'Failed to send email';
|
||||
|
||||
mailer.invite.mockImplementation(async () => {
|
||||
throw new Error(errorMsg);
|
||||
});
|
||||
|
||||
const response: InvitationResponse = await testServer
|
||||
.authAgentFor(instanceOwner)
|
||||
.post('/invitations')
|
||||
.send([{ email: randomEmail() }])
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toBeInstanceOf(Array);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
|
||||
const [result] = response.body.data;
|
||||
|
||||
expect(result.error).toBe(errorMsg);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { ModuleSettings } from '@n8n/decorators';
|
||||
|
||||
import { createMember, createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { setupTestServer } from '../shared/utils';
|
||||
|
||||
import { FrontendService } from '@/services/frontend.service';
|
||||
|
||||
describe('ModuleSettingsController', () => {
|
||||
const frontendService = mockInstance(FrontendService);
|
||||
|
||||
const testServer = setupTestServer({ endpointGroups: ['module-settings'] });
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let memberAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
memberAgent = testServer.authAgentFor(member);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /module-settings', () => {
|
||||
const mockSettings: { [key: string]: ModuleSettings } = { module: { some: 'settings' } };
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await testServer.authlessAgent.get('/module-settings').expect(401);
|
||||
});
|
||||
|
||||
it('should allow authenticated owner to get module settings', async () => {
|
||||
frontendService.getModuleSettings.mockReturnValue(mockSettings);
|
||||
|
||||
const response = await ownerAgent.get('/module-settings').expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: mockSettings });
|
||||
expect(frontendService.getModuleSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should allow authenticated member to get module settings', async () => {
|
||||
frontendService.getModuleSettings.mockReturnValue(mockSettings);
|
||||
|
||||
const response = await memberAgent.get('/module-settings').expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: mockSettings });
|
||||
expect(frontendService.getModuleSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle service errors gracefully', async () => {
|
||||
frontendService.getModuleSettings.mockImplementation(() => {
|
||||
throw new Error('Service error');
|
||||
});
|
||||
|
||||
await ownerAgent.get('/module-settings').expect(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { response as Response } from 'express';
|
||||
import nock from 'nock';
|
||||
import { parse as parseQs } from 'querystring';
|
||||
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import { saveCredential } from '@test-integration/db/credentials';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
describe('OAuth2 API', () => {
|
||||
const testServer = setupTestServer({ endpointGroups: ['oauth2'] });
|
||||
|
||||
let owner: User;
|
||||
let anotherUser: User;
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let credential: CredentialsEntity;
|
||||
const credentialData = {
|
||||
clientId: 'client_id',
|
||||
clientSecret: 'client_secret',
|
||||
authUrl: 'https://test.domain/oauth2/auth',
|
||||
accessTokenUrl: 'https://test.domain/oauth2/token',
|
||||
authQueryParameters: 'access_type=offline',
|
||||
};
|
||||
|
||||
CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
|
||||
decryptedDataOriginal;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
anotherUser = await createMember();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['SharedCredentials', 'CredentialsEntity']);
|
||||
credential = await saveCredential(
|
||||
{
|
||||
name: 'Test',
|
||||
type: 'testOAuth2Api',
|
||||
data: credentialData,
|
||||
},
|
||||
{
|
||||
user: owner,
|
||||
role: 'credential:owner',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a valid auth URL when the auth flow is initiated', async () => {
|
||||
const response = await ownerAgent
|
||||
.get('/oauth2-credential/auth')
|
||||
.query({ id: credential.id })
|
||||
.expect(200);
|
||||
const authUrl = new URL(response.body.data);
|
||||
expect(authUrl.hostname).toBe('test.domain');
|
||||
expect(authUrl.pathname).toBe('/oauth2/auth');
|
||||
|
||||
const queryParams = parseQs(authUrl.search.slice(1));
|
||||
expect(queryParams).toMatchObject({
|
||||
access_type: 'offline',
|
||||
client_id: 'client_id',
|
||||
redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
// Verify state is base64-encoded and contains expected structure
|
||||
expect(queryParams.state).toBeDefined();
|
||||
const decodedState = JSON.parse(Buffer.from(queryParams.state as string, 'base64').toString());
|
||||
expect(decodedState).toMatchObject({
|
||||
token: expect.any(String),
|
||||
createdAt: expect.any(Number),
|
||||
data: expect.any(String), // Encrypted CSRF data
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow external hook to modify oAuthOptions and state', async () => {
|
||||
const externalHooks = Container.get(ExternalHooks);
|
||||
const oauthService = Container.get(OauthService);
|
||||
|
||||
// Mock the external hook to modify both redirectUri and state
|
||||
const hookSpy = jest.fn(async function (oAuthOptions) {
|
||||
// Modify redirectUri directly in oAuthOptions
|
||||
oAuthOptions.redirectUri = 'https://custom.domain/callback';
|
||||
|
||||
// Decode base64 state, add host property, and re-encode
|
||||
const stateJson = JSON.parse(Buffer.from(oAuthOptions.state, 'base64').toString());
|
||||
stateJson.host = 'custom.host.com';
|
||||
oAuthOptions.state = Buffer.from(JSON.stringify(stateJson)).toString('base64');
|
||||
});
|
||||
|
||||
externalHooks['registered']['oauth2.authenticate'] = [hookSpy];
|
||||
|
||||
const response = await ownerAgent
|
||||
.get('/oauth2-credential/auth')
|
||||
.query({ id: credential.id })
|
||||
.expect(200);
|
||||
|
||||
const authUrl = new URL(response.body.data);
|
||||
const queryParams = parseQs(authUrl.search.slice(1));
|
||||
|
||||
// Verify the hook was called
|
||||
expect(hookSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify redirectUri was modified
|
||||
expect(queryParams.redirect_uri).toBe('https://custom.domain/callback');
|
||||
|
||||
// Verify the state is base64-encoded
|
||||
expect(queryParams.state).toBeDefined();
|
||||
expect(typeof queryParams.state).toBe('string');
|
||||
|
||||
// Decode and verify the state contains the host property (plaintext in base64)
|
||||
const decodedState = JSON.parse(Buffer.from(queryParams.state as string, 'base64').toString());
|
||||
expect(decodedState.host).toBe('custom.host.com');
|
||||
expect(decodedState.token).toBeDefined();
|
||||
expect(decodedState.createdAt).toBeDefined();
|
||||
expect(decodedState.data).toBeDefined();
|
||||
|
||||
// Decrypt the data field and verify original CSRF data is preserved
|
||||
const decryptedData = JSON.parse(oauthService['cipher'].decrypt(decodedState.data));
|
||||
expect(decryptedData.cid).toBe(credential.id);
|
||||
expect(decryptedData.userId).toBe(owner.id);
|
||||
});
|
||||
|
||||
it('should fail on auth when callback is called as another user', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [_, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
await testServer
|
||||
.authAgentFor(anotherUser)
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state })
|
||||
.expect(200);
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: { message: 'Unauthorized' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a valid callback without auth', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [_, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
nock('https://test.domain').post('/oauth2/token').reply(200, { access_token: 'updated_token' });
|
||||
|
||||
await ownerAgent
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state })
|
||||
.expect(200);
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
|
||||
|
||||
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
|
||||
credential,
|
||||
credential.type,
|
||||
);
|
||||
expect(updatedCredential.getData()).toEqual({
|
||||
...credentialData,
|
||||
oauthTokenData: { access_token: 'updated_token' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Integration tests for OAuth2 callback with N8N_SKIP_AUTH_ON_OAUTH_CALLBACK=true
|
||||
*
|
||||
* IMPORTANT: Environment variable must be set before module imports
|
||||
* because skipAuthOnOAuthCallback is evaluated at module load time.
|
||||
*/
|
||||
|
||||
// Set environment variable before any imports
|
||||
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = 'true';
|
||||
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { response as Response } from 'express';
|
||||
import nock from 'nock';
|
||||
import { parse as parseQs } from 'querystring';
|
||||
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import { saveCredential } from '@test-integration/db/credentials';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import type { SuperAgentTest } from '@test-integration/types';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
describe('OAuth2 API with skipAuthOnOAuthCallback enabled', () => {
|
||||
const testServer = setupTestServer({ endpointGroups: ['oauth2'] });
|
||||
|
||||
let owner: User;
|
||||
let anotherUser: User;
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let credential: CredentialsEntity;
|
||||
const credentialData = {
|
||||
clientId: 'client_id',
|
||||
clientSecret: 'client_secret',
|
||||
authUrl: 'https://test.domain/oauth2/auth',
|
||||
accessTokenUrl: 'https://test.domain/oauth2/token',
|
||||
authQueryParameters: 'access_type=offline',
|
||||
};
|
||||
|
||||
CredentialsHelper.prototype.applyDefaultsAndOverwrites = async (_, decryptedDataOriginal) =>
|
||||
decryptedDataOriginal;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwner();
|
||||
anotherUser = await createMember();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['SharedCredentials', 'CredentialsEntity']);
|
||||
credential = await saveCredential(
|
||||
{
|
||||
name: 'Test',
|
||||
type: 'testOAuth2Api',
|
||||
data: credentialData,
|
||||
},
|
||||
{
|
||||
user: owner,
|
||||
role: 'credential:owner',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up environment variable
|
||||
delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK;
|
||||
});
|
||||
|
||||
describe('OAuth callback without authentication', () => {
|
||||
it('should handle OAuth callback without authentication when skipAuthOnOAuthCallback is enabled', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
// Step 1: Owner initiates OAuth flow (authenticated)
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [_, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
// Step 2: Mock external OAuth provider response
|
||||
nock('https://test.domain')
|
||||
.post('/oauth2/token')
|
||||
.reply(200, { access_token: 'new_access_token' });
|
||||
|
||||
// Step 3: Callback arrives WITHOUT authentication
|
||||
// This simulates the real-world scenario where skipAuth: true is configured
|
||||
// and the OAuth provider redirects back without going through auth middleware
|
||||
await testServer.authlessAgent
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state })
|
||||
.expect(200);
|
||||
|
||||
// Verify success - should NOT render error page
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
|
||||
expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());
|
||||
|
||||
// Verify credential was updated with OAuth token
|
||||
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
|
||||
credential,
|
||||
credential.type,
|
||||
);
|
||||
expect(updatedCredential.getData()).toEqual({
|
||||
...credentialData,
|
||||
oauthTokenData: { access_token: 'new_access_token' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow callback completion by any user when skipAuthOnOAuthCallback is enabled', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
// Step 1: Owner initiates OAuth flow
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [_, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
// Step 2: Mock external OAuth provider response
|
||||
nock('https://test.domain')
|
||||
.post('/oauth2/token')
|
||||
.reply(200, { access_token: 'different_user_token' });
|
||||
|
||||
// Step 3: Different user completes the callback
|
||||
// When skipAuth is enabled, userId validation is skipped
|
||||
// This is intentional for scenarios where auth middleware cannot run
|
||||
await testServer
|
||||
.authAgentFor(anotherUser)
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state })
|
||||
.expect(200);
|
||||
|
||||
// Should succeed without error
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-callback');
|
||||
expect(renderSpy).not.toHaveBeenCalledWith('oauth-error-callback', expect.anything());
|
||||
|
||||
// Verify credential was updated
|
||||
const updatedCredential = await Container.get(CredentialsHelper).getCredentials(
|
||||
credential,
|
||||
credential.type,
|
||||
);
|
||||
expect(updatedCredential.getData()).toEqual({
|
||||
...credentialData,
|
||||
oauthTokenData: { access_token: 'different_user_token' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth flow initiation', () => {
|
||||
it('should return a valid auth URL when the auth flow is initiated', async () => {
|
||||
const response = await ownerAgent
|
||||
.get('/oauth2-credential/auth')
|
||||
.query({ id: credential.id })
|
||||
.expect(200);
|
||||
|
||||
const authUrl = new URL(response.body.data);
|
||||
expect(authUrl.hostname).toBe('test.domain');
|
||||
expect(authUrl.pathname).toBe('/oauth2/auth');
|
||||
|
||||
const queryParams = parseQs(authUrl.search.slice(1));
|
||||
expect(queryParams).toMatchObject({
|
||||
access_type: 'offline',
|
||||
client_id: 'client_id',
|
||||
redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
// Verify state is base64-encoded and contains expected structure
|
||||
expect(queryParams.state).toBeDefined();
|
||||
const decodedState = JSON.parse(
|
||||
Buffer.from(queryParams.state as string, 'base64').toString(),
|
||||
);
|
||||
expect(decodedState).toMatchObject({
|
||||
token: expect.any(String),
|
||||
createdAt: expect.any(Number),
|
||||
data: expect.any(String), // Encrypted CSRF data
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should still validate CSRF state even when skipAuthOnOAuthCallback is enabled', async () => {
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
// Attempt callback with invalid state
|
||||
await testServer.authlessAgent
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state: 'invalid_state' })
|
||||
.expect(200);
|
||||
|
||||
// Should render error due to invalid CSRF state
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: expect.objectContaining({
|
||||
message: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle OAuth provider errors gracefully', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
// Initiate OAuth flow
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [_, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
// Mock OAuth provider returning an error
|
||||
nock('https://test.domain').post('/oauth2/token').reply(400, {
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Authorization code has expired',
|
||||
});
|
||||
|
||||
// Callback should handle provider error
|
||||
await testServer.authlessAgent
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'expired_code', state })
|
||||
.expect(200);
|
||||
|
||||
// Should render error callback
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: expect.objectContaining({
|
||||
message: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security validation', () => {
|
||||
it('should not skip CSRF token validation when skipAuthOnOAuthCallback is enabled', async () => {
|
||||
const oauthService = Container.get(OauthService);
|
||||
const csrfSpy = jest.spyOn(oauthService, 'createCsrfState').mockClear();
|
||||
const renderSpy = (Response.render = jest.fn(function () {
|
||||
this.end();
|
||||
}));
|
||||
|
||||
// Initiate OAuth flow to get a valid state
|
||||
await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200);
|
||||
|
||||
const [__, state] = csrfSpy.mock.results[0].value;
|
||||
|
||||
// Tamper with the state (decrypt, modify, re-encrypt would be needed)
|
||||
// For this test, we'll use a completely different valid-looking but wrong state
|
||||
const tamperedState = state.replace(/[a-z]/, 'x');
|
||||
|
||||
// Attempt callback with tampered state
|
||||
await testServer.authlessAgent
|
||||
.get('/oauth2-credential/callback')
|
||||
.query({ code: 'auth_code', state: tamperedState })
|
||||
.expect(200);
|
||||
|
||||
// Should render error due to CSRF validation failure
|
||||
expect(renderSpy).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: expect.objectContaining({
|
||||
message: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types';
|
||||
import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils';
|
||||
import {
|
||||
PROJECT_ADMIN_ROLE,
|
||||
PROJECT_EDITOR_ROLE,
|
||||
PROJECT_OWNER_ROLE,
|
||||
PROJECT_VIEWER_ROLE,
|
||||
RoleRepository,
|
||||
} from '@n8n/db';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { cleanupRolesAndScopes } from '../shared/db/roles';
|
||||
import { createMember, createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { setupTestServer } from '../shared/utils';
|
||||
|
||||
describe('RoleController - Integration Tests', () => {
|
||||
const testServer = setupTestServer({ endpointGroups: ['role'] });
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let memberAgent: SuperAgentTest;
|
||||
let owner: User;
|
||||
let member: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
owner = await createOwner();
|
||||
member = await createMember();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
memberAgent = testServer.authAgentFor(member);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
testServer.license.enable('feat:customRoles');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRolesAndScopes();
|
||||
await Container.get(RoleRepository).delete({ systemRole: false });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('GET /roles/:slug', () => {
|
||||
const staticRoles = [PROJECT_ADMIN_ROLE, PROJECT_EDITOR_ROLE, PROJECT_VIEWER_ROLE];
|
||||
|
||||
it.each(staticRoles)('should return 200 and the role data for role $slug', async (role) => {
|
||||
const response = await memberAgent.get(`/roles/${role.slug}`).expect(200);
|
||||
|
||||
response.body.data.scopes.sort();
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
slug: role.slug,
|
||||
displayName: role.displayName,
|
||||
description: role.description,
|
||||
systemRole: role.systemRole,
|
||||
roleType: role.roleType,
|
||||
scopes: role.scopes.map((scope) => scope.slug).sort(),
|
||||
licensed: expect.any(Boolean),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 and the role data for PROJECT_OWNER_ROLE with dynamic scopes', async () => {
|
||||
// PROJECT_OWNER_ROLE has conditional scopes based on security settings.
|
||||
// The workflow:publish scope is dynamically added/removed based on the
|
||||
// personal space publishing setting. We fetch the actual role from the
|
||||
// database to get the current scopes.
|
||||
const roleRepository = Container.get(RoleRepository);
|
||||
const dbRole = await roleRepository.findBySlug(PROJECT_OWNER_ROLE.slug);
|
||||
expect(dbRole).not.toBeNull();
|
||||
|
||||
const response = await memberAgent.get(`/roles/${PROJECT_OWNER_ROLE.slug}`).expect(200);
|
||||
|
||||
response.body.data.scopes.sort();
|
||||
const expectedScopes = dbRole!.scopes.map((scope) => scope.slug).sort();
|
||||
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
slug: PROJECT_OWNER_ROLE.slug,
|
||||
displayName: PROJECT_OWNER_ROLE.displayName,
|
||||
description: PROJECT_OWNER_ROLE.description,
|
||||
systemRole: PROJECT_OWNER_ROLE.systemRole,
|
||||
roleType: PROJECT_OWNER_ROLE.roleType,
|
||||
scopes: expectedScopes,
|
||||
licensed: expect.any(Boolean),
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /roles', () => {
|
||||
it('should create a custom role', async () => {
|
||||
const createRoleDto: CreateRoleDto = {
|
||||
displayName: 'Custom Project Role',
|
||||
description: 'A custom role for project management',
|
||||
roleType: 'project',
|
||||
scopes: ['workflow:create', 'workflow:read'].sort(),
|
||||
};
|
||||
|
||||
const response = await ownerAgent.post('/roles').send(createRoleDto).expect(200);
|
||||
|
||||
response.body.data.scopes.sort();
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
...createRoleDto,
|
||||
slug: expect.any(String),
|
||||
licensed: expect.any(Boolean),
|
||||
systemRole: false,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
|
||||
const availableRole = await memberAgent.get(`/roles/${response.body.data.slug}`).expect(200);
|
||||
|
||||
availableRole.body.data.scopes.sort();
|
||||
expect(availableRole.body).toEqual({
|
||||
data: {
|
||||
...createRoleDto,
|
||||
slug: response.body.data.slug,
|
||||
licensed: expect.any(Boolean),
|
||||
systemRole: false,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /roles/:slug', () => {
|
||||
it('should update a custom role', async () => {
|
||||
const createRoleDto: CreateRoleDto = {
|
||||
displayName: 'Custom Project Role',
|
||||
description: 'A custom role for project management',
|
||||
roleType: 'project',
|
||||
scopes: ['workflow:create', 'workflow:read'].sort(),
|
||||
};
|
||||
|
||||
const createResponse = await ownerAgent.post('/roles').send(createRoleDto).expect(200);
|
||||
|
||||
expect(createResponse.body?.data?.slug).toBeDefined();
|
||||
const generatedRoleSlug = createResponse.body.data.slug;
|
||||
|
||||
const updateRoleDto: UpdateRoleDto = {
|
||||
displayName: 'Custom Project Role Updated',
|
||||
description: 'A custom role for project management - updated',
|
||||
};
|
||||
|
||||
const response = await ownerAgent
|
||||
.patch(`/roles/${generatedRoleSlug}`)
|
||||
.send(updateRoleDto)
|
||||
.expect(200);
|
||||
|
||||
response.body.data.scopes.sort();
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
...updateRoleDto,
|
||||
scopes: ['workflow:create', 'workflow:read'].sort(),
|
||||
slug: generatedRoleSlug,
|
||||
roleType: 'project',
|
||||
licensed: expect.any(Boolean),
|
||||
systemRole: false,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
|
||||
const availableRole = await memberAgent.get(`/roles/${response.body.data.slug}`).expect(200);
|
||||
|
||||
availableRole.body.data.scopes.sort();
|
||||
expect(availableRole.body).toEqual({
|
||||
data: {
|
||||
...updateRoleDto,
|
||||
scopes: ['workflow:create', 'workflow:read'].sort(),
|
||||
slug: generatedRoleSlug,
|
||||
roleType: 'project',
|
||||
licensed: expect.any(Boolean),
|
||||
systemRole: false,
|
||||
createdAt: expect.any(String),
|
||||
updatedAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /roles/:slug/assignments', () => {
|
||||
it('should return projects where the role is assigned', async () => {
|
||||
const project = await createTeamProject('Test Project', owner);
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
|
||||
const response = await ownerAgent
|
||||
.get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.totalProjects).toBeGreaterThanOrEqual(1);
|
||||
const projectNames = response.body.data.projects.map(
|
||||
(p: { projectName: string }) => p.projectName,
|
||||
);
|
||||
expect(projectNames).toContain('Test Project');
|
||||
|
||||
const testProject = response.body.data.projects.find(
|
||||
(p: { projectName: string }) => p.projectName === 'Test Project',
|
||||
);
|
||||
expect(testProject.memberCount).toBe(1);
|
||||
expect(testProject.projectId).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should return empty when role has no assignments', async () => {
|
||||
const response = await ownerAgent
|
||||
.get(`/roles/${PROJECT_VIEWER_ROLE.slug}/assignments`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.totalProjects).toBe(0);
|
||||
expect(response.body.data.projects).toEqual([]);
|
||||
});
|
||||
|
||||
it('should require role:manage scope (deny member)', async () => {
|
||||
await memberAgent.get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /roles/:slug/assignments/:projectId/members', () => {
|
||||
it('should return only members with the specified role', async () => {
|
||||
const project = await createTeamProject('Members Test', owner);
|
||||
await linkUserToProject(member, project, 'project:editor');
|
||||
// owner is project:admin via createTeamProject
|
||||
|
||||
const response = await ownerAgent
|
||||
.get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${project.id}/members`)
|
||||
.expect(200);
|
||||
|
||||
// Should only include the editor, not the admin
|
||||
expect(response.body.data.members).toHaveLength(1);
|
||||
expect(response.body.data.members[0].email).toBe(member.email);
|
||||
expect(response.body.data.members[0].role).toBe('project:editor');
|
||||
});
|
||||
|
||||
it('should require role:manage scope (deny member)', async () => {
|
||||
const project = await createTeamProject('Auth Test');
|
||||
|
||||
await memberAgent
|
||||
.get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${project.id}/members`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import {
|
||||
PERSONAL_SPACE_PUBLISHING_SETTING,
|
||||
PERSONAL_SPACE_SHARING_SETTING,
|
||||
} from '@n8n/permissions';
|
||||
|
||||
import { SecuritySettingsService } from '@/services/security-settings.service';
|
||||
|
||||
import { createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { setupTestServer } from '../shared/utils';
|
||||
|
||||
describe('SecuritySettingsController', () => {
|
||||
const securitySettingsService = mockInstance(SecuritySettingsService);
|
||||
|
||||
const testServer = setupTestServer({ endpointGroups: ['security-settings'] });
|
||||
let ownerAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
const owner = await createOwner();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
testServer.license.enable('feat:personalSpacePolicy');
|
||||
});
|
||||
|
||||
describe('GET /settings/security', () => {
|
||||
it('should return 403 when personalSpacePolicy license is not active', async () => {
|
||||
testServer.license.disable('feat:personalSpacePolicy');
|
||||
await ownerAgent.get('/settings/security').expect(403);
|
||||
});
|
||||
|
||||
it('should return security settings and all counts', async () => {
|
||||
securitySettingsService.arePersonalSpaceSettingsEnabled.mockResolvedValue({
|
||||
personalSpacePublishing: true,
|
||||
personalSpaceSharing: false,
|
||||
});
|
||||
securitySettingsService.getPublishedPersonalWorkflowsCount.mockResolvedValue(5);
|
||||
securitySettingsService.getSharedPersonalWorkflowsCount.mockResolvedValue(12);
|
||||
securitySettingsService.getSharedPersonalCredentialsCount.mockResolvedValue(3);
|
||||
|
||||
const response = await ownerAgent.get('/settings/security').expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
personalSpacePublishing: true,
|
||||
personalSpaceSharing: false,
|
||||
publishedPersonalWorkflowsCount: 5,
|
||||
sharedPersonalWorkflowsCount: 12,
|
||||
sharedPersonalCredentialsCount: 3,
|
||||
},
|
||||
});
|
||||
expect(securitySettingsService.arePersonalSpaceSettingsEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(securitySettingsService.getPublishedPersonalWorkflowsCount).toHaveBeenCalledTimes(1);
|
||||
expect(securitySettingsService.getSharedPersonalWorkflowsCount).toHaveBeenCalledTimes(1);
|
||||
expect(securitySettingsService.getSharedPersonalCredentialsCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return 0 for all counts when no resources exist', async () => {
|
||||
securitySettingsService.arePersonalSpaceSettingsEnabled.mockResolvedValue({
|
||||
personalSpacePublishing: true,
|
||||
personalSpaceSharing: true,
|
||||
});
|
||||
securitySettingsService.getPublishedPersonalWorkflowsCount.mockResolvedValue(0);
|
||||
securitySettingsService.getSharedPersonalWorkflowsCount.mockResolvedValue(0);
|
||||
securitySettingsService.getSharedPersonalCredentialsCount.mockResolvedValue(0);
|
||||
|
||||
const response = await ownerAgent.get('/settings/security').expect(200);
|
||||
|
||||
expect(response.body.data.publishedPersonalWorkflowsCount).toBe(0);
|
||||
expect(response.body.data.sharedPersonalWorkflowsCount).toBe(0);
|
||||
expect(response.body.data.sharedPersonalCredentialsCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle service errors gracefully', async () => {
|
||||
securitySettingsService.arePersonalSpaceSettingsEnabled.mockRejectedValue(
|
||||
new Error('Database connection failed'),
|
||||
);
|
||||
|
||||
await ownerAgent.get('/settings/security').expect(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /settings/security', () => {
|
||||
it('should return 403 when personalSpacePolicy license is not active', async () => {
|
||||
testServer.license.disable('feat:personalSpacePolicy');
|
||||
await ownerAgent
|
||||
.post('/settings/security')
|
||||
.send({ personalSpacePublishing: true })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('should update only personalSpacePublishing when only that is set in body', async () => {
|
||||
securitySettingsService.setPersonalSpaceSetting.mockResolvedValue(undefined);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/settings/security')
|
||||
.send({ personalSpacePublishing: false })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
data: { personalSpacePublishing: false },
|
||||
});
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenCalledTimes(1);
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenCalledWith(
|
||||
PERSONAL_SPACE_PUBLISHING_SETTING,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update only personalSpaceSharing when only that is set in body', async () => {
|
||||
securitySettingsService.setPersonalSpaceSetting.mockResolvedValue(undefined);
|
||||
const response = await ownerAgent
|
||||
.post('/settings/security')
|
||||
.send({ personalSpaceSharing: true })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
data: { personalSpaceSharing: true },
|
||||
});
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenCalledTimes(1);
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenCalledWith(
|
||||
PERSONAL_SPACE_SHARING_SETTING,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update both settings when both are set in body', async () => {
|
||||
securitySettingsService.setPersonalSpaceSetting.mockResolvedValue(undefined);
|
||||
|
||||
const response = await ownerAgent
|
||||
.post('/settings/security')
|
||||
.send({ personalSpacePublishing: true, personalSpaceSharing: false })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
data: {
|
||||
personalSpacePublishing: true,
|
||||
personalSpaceSharing: false,
|
||||
},
|
||||
});
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenCalledTimes(2);
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
PERSONAL_SPACE_PUBLISHING_SETTING,
|
||||
true,
|
||||
);
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
PERSONAL_SPACE_SHARING_SETTING,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call no service and return empty object when body has no settings', async () => {
|
||||
const response = await ownerAgent.post('/settings/security').send({}).expect(200);
|
||||
|
||||
expect(response.body).toEqual({ data: {} });
|
||||
expect(securitySettingsService.setPersonalSpaceSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle service errors gracefully', async () => {
|
||||
securitySettingsService.setPersonalSpaceSetting.mockRejectedValue(
|
||||
new Error('Database connection failed'),
|
||||
);
|
||||
|
||||
await ownerAgent
|
||||
.post('/settings/security')
|
||||
.send({ personalSpacePublishing: true })
|
||||
.expect(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createMember, createOwner } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import { setupTestServer } from '../shared/utils';
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
}));
|
||||
|
||||
import { readFile } from 'fs/promises';
|
||||
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
|
||||
|
||||
describe('ThirdPartyLicensesController', () => {
|
||||
const testServer = setupTestServer({ endpointGroups: ['third-party-licenses'] });
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let memberAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
memberAgent = testServer.authAgentFor(member);
|
||||
});
|
||||
|
||||
describe('GET /third-party-licenses', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await testServer.authlessAgent.get('/third-party-licenses').expect(401);
|
||||
});
|
||||
|
||||
describe('when license file exists', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockResolvedValue('# Third Party Licenses\n\nSome license content...');
|
||||
});
|
||||
|
||||
it('should allow authenticated owner to get third-party licenses', async () => {
|
||||
const response = await ownerAgent.get('/third-party-licenses');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toMatch(/text\/markdown/);
|
||||
expect(response.text).toBe('# Third Party Licenses\n\nSome license content...');
|
||||
});
|
||||
|
||||
it('should allow authenticated member to get third-party licenses', async () => {
|
||||
const response = await memberAgent.get('/third-party-licenses');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toMatch(/text\/markdown/);
|
||||
expect(response.text).toBe('# Third Party Licenses\n\nSome license content...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when license file does not exist', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
});
|
||||
|
||||
it('should return 404 for authenticated owner', async () => {
|
||||
const response = await ownerAgent.get('/third-party-licenses');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).toBe('Third-party licenses file not found');
|
||||
});
|
||||
|
||||
it('should return 404 for authenticated member', async () => {
|
||||
const response = await memberAgent.get('/third-party-licenses');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).toBe('Third-party licenses file not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when file read fails with other errors', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'));
|
||||
});
|
||||
|
||||
it('should return 404 for permission errors', async () => {
|
||||
const response = await ownerAgent.get('/third-party-licenses');
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.text).toBe('Third-party licenses file not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should request the correct file path', async () => {
|
||||
mockReadFile.mockResolvedValue('test content');
|
||||
|
||||
await ownerAgent.get('/third-party-licenses');
|
||||
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/THIRD_PARTY_LICENSES\.md$/),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user