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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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);
};
@@ -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',
);
});
});
});
});