mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 17:57:06 +02:00
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
import {
|
|
createTeamProject,
|
|
linkUserToProject,
|
|
createWorkflow,
|
|
testDb,
|
|
mockInstance,
|
|
} from '@n8n/backend-test-utils';
|
|
import type { User } from '@n8n/db';
|
|
import { stringify } from 'flatted';
|
|
import type { IRunExecutionData, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow';
|
|
import { createRunExecutionData } from 'n8n-workflow';
|
|
|
|
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
|
import { WaitTracker } from '@/wait-tracker';
|
|
|
|
import { createExecution } from './shared/db/executions';
|
|
import { createMember, createOwner } from './shared/db/users';
|
|
import { setupTestServer } from './shared/utils';
|
|
|
|
// Must be set before setupTestServer() so RedactionModule.init() wires the real service
|
|
process.env.N8N_ENV_FEAT_EXECUTION_REDACTION = 'true';
|
|
|
|
mockInstance(WaitTracker);
|
|
mockInstance(ConcurrencyControlService, {
|
|
// @ts-expect-error Private property
|
|
isEnabled: false,
|
|
});
|
|
|
|
const testServer = setupTestServer({
|
|
endpointGroups: ['executions'],
|
|
modules: ['redaction'],
|
|
});
|
|
|
|
let owner: User;
|
|
let member: User;
|
|
|
|
beforeEach(async () => {
|
|
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity', 'SharedWorkflow']);
|
|
testServer.license.reset();
|
|
owner = await createOwner();
|
|
member = await createMember();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SENSITIVE_JSON = { secret: 'sensitive-value', apiKey: 'sk-1234' };
|
|
|
|
const BINARY_DATA = {
|
|
data: 'base64data',
|
|
mimeType: 'text/plain',
|
|
fileName: 'secret.txt',
|
|
};
|
|
|
|
function buildRunExecutionData(opts: {
|
|
policy?: 'none' | 'non-manual' | 'all';
|
|
mode?: WorkflowExecuteMode;
|
|
}): IRunExecutionData {
|
|
return createRunExecutionData({
|
|
resultData: {
|
|
runData: {
|
|
'Test Node': [
|
|
{
|
|
startTime: 0,
|
|
executionIndex: 0,
|
|
executionTime: 0,
|
|
executionStatus: 'success',
|
|
source: [],
|
|
data: {
|
|
main: [
|
|
[
|
|
{
|
|
json: { ...SENSITIVE_JSON },
|
|
binary: { file: { ...BINARY_DATA } },
|
|
},
|
|
],
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
executionData: opts.policy
|
|
? {
|
|
runtimeData: {
|
|
version: 1 as const,
|
|
establishedAt: Date.now(),
|
|
source: opts.mode ?? 'trigger',
|
|
redaction: { version: 1 as const, policy: opts.policy },
|
|
},
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
async function createExecutionWithRedaction(opts: {
|
|
workflow: IWorkflowBase;
|
|
mode?: WorkflowExecuteMode;
|
|
policy?: 'none' | 'non-manual' | 'all';
|
|
}) {
|
|
const runData = buildRunExecutionData({ policy: opts.policy, mode: opts.mode });
|
|
return await createExecution(
|
|
{ data: stringify(runData), mode: opts.mode ?? 'trigger' },
|
|
opts.workflow,
|
|
);
|
|
}
|
|
|
|
function parseResponseData(responseBody: { data: { data: string } }): IRunExecutionData {
|
|
// The response data field is flatted-stringified
|
|
const { parse } = require('flatted');
|
|
return parse(responseBody.data.data) as IRunExecutionData;
|
|
}
|
|
|
|
function assertRedacted(
|
|
data: IRunExecutionData,
|
|
expectedReason = 'workflow_redaction_policy',
|
|
expectedCanReveal = true,
|
|
) {
|
|
const items = data.resultData.runData['Test Node'][0].data!.main[0]!;
|
|
expect(items.length).toBeGreaterThan(0);
|
|
for (const item of items) {
|
|
expect(item.json).toEqual({});
|
|
expect(item.binary).toBeUndefined();
|
|
expect(item.redaction).toEqual({ redacted: true, reason: expectedReason });
|
|
}
|
|
expect(data.redactionInfo).toEqual({
|
|
isRedacted: true,
|
|
reason: expectedReason,
|
|
canReveal: expectedCanReveal,
|
|
});
|
|
}
|
|
|
|
function assertNotRedacted(data: IRunExecutionData) {
|
|
const items = data.resultData.runData['Test Node'][0].data!.main[0]!;
|
|
expect(items.length).toBeGreaterThan(0);
|
|
for (const item of items) {
|
|
expect(item.json).toEqual(expect.objectContaining({ secret: 'sensitive-value' }));
|
|
}
|
|
expect(data.redactionInfo).toBeUndefined();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /executions/:id — Execution Redaction', () => {
|
|
describe('policy-based redaction (no query param)', () => {
|
|
test('policy "none" with trigger mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "none" with manual mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'manual',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "non-manual" with manual mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'manual',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "non-manual" with trigger mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "non-manual" with webhook mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'webhook',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "all" with manual mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'manual',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('policy "all" with trigger mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('no runtimeData and no workflow settings — defaults to "none", returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
// No policy passed → no runtimeData.redaction
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('no runtimeData but workflow settings has policy "all" — falls back to settings, returns redacted', async () => {
|
|
const workflow = await createWorkflow({ settings: { redactionPolicy: 'all' } }, owner);
|
|
// No runtimeData.redaction → falls back to workflowData.settings.redactionPolicy
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=true query param', () => {
|
|
test('always redacts even when policy is "none"', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.query({ redactExecutionData: 'true' })
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body), 'user_requested');
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=false query param (reveal)', () => {
|
|
test('owner can reveal unredacted data', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.query({ redactExecutionData: 'false' })
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('project editor without execution:reveal scope gets 403', async () => {
|
|
testServer.license.enable('feat:sharing');
|
|
|
|
const teamProject = await createTeamProject();
|
|
await linkUserToProject(member, teamProject, 'project:editor');
|
|
|
|
const workflow = await createWorkflow({}, teamProject);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
await testServer
|
|
.authAgentFor(member)
|
|
.get(`/executions/${execution.id}`)
|
|
.query({ redactExecutionData: 'false' })
|
|
.expect(403);
|
|
});
|
|
|
|
test('project editor without execution:reveal scope can still reveal when policy allows it (policy=none)', async () => {
|
|
testServer.license.enable('feat:sharing');
|
|
|
|
const teamProject = await createTeamProject();
|
|
await linkUserToProject(member, teamProject, 'project:editor');
|
|
|
|
const workflow = await createWorkflow({}, teamProject);
|
|
// policy='none' means policyAllowsReveal=true — no permission check needed
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(member)
|
|
.get(`/executions/${execution.id}`)
|
|
.query({ redactExecutionData: 'false' })
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
});
|
|
|
|
describe('policy fallback precedence', () => {
|
|
test('runtimeData policy takes precedence over workflowData.settings', async () => {
|
|
// Workflow settings say "all" but runtimeData says "none"
|
|
const workflow = await createWorkflow({ settings: { redactionPolicy: 'all' } }, owner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none', // runtimeData wins
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(parseResponseData(response.body));
|
|
});
|
|
|
|
test('falls back to workflowData.settings when runtimeData is missing', async () => {
|
|
const workflow = await createWorkflow({ settings: { redactionPolicy: 'all' } }, owner);
|
|
// No policy → no runtimeData.redaction → falls back to settings
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
assertRedacted(parseResponseData(response.body));
|
|
});
|
|
});
|
|
});
|