n8n/packages/cli/test/integration/credentials/instance-credentials.api.test.ts
Charlie Kolb e5f56a77bb
refactor(core): Move the response errors into @n8n/backend-services (no-changelog)
Move the generic ResponseError hierarchy (BadRequestError, NotFoundError,
ForbiddenError, ...) into @n8n/backend-services and point every cli
import at the package root. 29 backend modules import these classes.

Feature-specific response errors (MFA codes, license EULA, credential
and workflow transfer, webhook not found, workflow activation and
validation) stay in cli next to the code that throws them. They extend
the generic classes from the package. WorkflowPublishBlockedError moves
because three modules import it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 10:25:06 +02:00

195 lines
6.6 KiB
TypeScript

import { UnprocessableRequestError } from '@n8n/backend-services';
import { mockInstance, randomCredentialPayload, testDb } from '@n8n/backend-test-utils';
import type { ICredentialsDb, User } from '@n8n/db';
import {
CredentialsEntity,
CredentialsRepository,
InstanceCredentialAssignmentRepository,
SharedCredentialsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { CredentialsService } from '@/credentials/credentials.service';
import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import type { InstanceCredentialUse } from '@/credentials/instance-credential-use.registry';
import { ExternalHooks } from '@/external-hooks';
import {
createCredentials,
decryptCredentialData,
encryptCredentialData,
} from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { initCredentialsTypes, setupTestServer } from '../shared/utils';
const externalHooks = mockInstance(ExternalHooks);
const testServer = setupTestServer({ endpointGroups: ['credentials'] });
const TEST_CREDENTIAL_USE = {
id: 'test:instance-credential',
credentialTypes: ['httpHeaderAuth'],
validate: ({ data }) => {
if (data.name !== 'x-api-key') throw new UnprocessableRequestError('Invalid header name');
},
} satisfies InstanceCredentialUse;
let owner: User;
let member: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
beforeAll(async () => {
await initCredentialsTypes();
Container.get(InstanceCredentialBroker).registerUse(TEST_CREDENTIAL_USE);
owner = await createOwner();
member = await createMember();
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
});
beforeEach(async () => {
externalHooks.run.mockReset();
await testDb.truncate(['InstanceCredentialAssignment', 'SharedCredentials', 'CredentialsEntity']);
});
async function createInstanceCredential(payload = randomCredentialPayload()) {
const entity = new CredentialsEntity();
Object.assign(entity, payload, { usageScope: 'instance' });
await encryptCredentialData(entity);
return await createCredentials(entity);
}
describe('instance credentials', () => {
test('owner creates one without any sharing rows', async () => {
const payload = { ...randomCredentialPayload(), usageScope: 'instance' };
const response = await authOwnerAgent.post('/credentials').send(payload);
expect(response.statusCode).toBe(200);
const { id } = response.body.data;
await expect(
Container.get(SharedCredentialsRepository).findBy({ credentialsId: id }),
).resolves.toEqual([]);
});
test('member cannot create one', async () => {
const payload = { ...randomCredentialPayload(), usageScope: 'instance' };
await authMemberAgent.post('/credentials').send(payload).expect(403);
});
test('never appear in the credentials list', async () => {
const credential = await createInstanceCredential();
for (const agent of [authOwnerAgent, authMemberAgent]) {
const response = await agent.get('/credentials').expect(200);
const ids = response.body.data.map((c: { id: string }) => c.id);
expect(ids).not.toContain(credential.id);
}
});
test('member cannot read, update, or delete one', async () => {
const credential = await createInstanceCredential();
await authMemberAgent.get(`/credentials/${credential.id}`).expect(404);
await authMemberAgent
.patch(`/credentials/${credential.id}`)
.send(randomCredentialPayload())
.expect(404);
await authMemberAgent.delete(`/credentials/${credential.id}`).expect(404);
});
test('owner reads one without exposing plain secrets', async () => {
const payload = randomCredentialPayload();
const credential = await createInstanceCredential(payload);
const response = await authOwnerAgent
.get(`/credentials/${credential.id}`)
.query({ includeData: true })
.expect(200);
expect(response.body.data.id).toBe(credential.id);
expect(response.body.data.data?.accessToken).not.toBe(payload.data.accessToken);
});
test('owner can delete one', async () => {
const credential = await createInstanceCredential();
await authOwnerAgent.delete(`/credentials/${credential.id}`).expect(200);
});
describe('assigned to an instance credential use', () => {
const sandboxPayload = {
name: 'n8n Assistant sandbox',
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'secret' },
};
async function createAssignedSandboxCredential() {
const credential = await createInstanceCredential(sandboxPayload);
await Container.get(InstanceCredentialAssignmentRepository).assignCredential(
TEST_CREDENTIAL_USE.id,
credential.id,
TEST_CREDENTIAL_USE.credentialTypes,
);
return credential;
}
test('owner can update an assigned credential', async () => {
const credential = await createAssignedSandboxCredential();
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'x-api-key', value: 'rotated' } })
.expect(200);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({ value: 'rotated' });
});
test('owner cannot update an assigned credential with data rejected by its use', async () => {
const credential = await createAssignedSandboxCredential();
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'Authorization', value: 'secret' } })
.expect(422);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({ name: 'x-api-key' });
});
test('owner cannot update one when a hook makes it invalid for its use', async () => {
const credential = await createAssignedSandboxCredential();
const invalid = await Container.get(CredentialsService).createEncryptedData({
id: credential.id,
name: credential.name,
type: credential.type,
data: { name: 'Authorization', value: 'hooked' },
});
externalHooks.run.mockImplementation(async (event, hookParameters) => {
if (event !== 'credentials.update') return;
const [hooked] = hookParameters as [ICredentialsDb];
hooked.data = invalid.data;
});
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'x-api-key', value: 'rotated' } })
.expect(422);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({
name: 'x-api-key',
value: 'secret',
});
});
});
});