mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-23 12:55:23 +02:00
921 lines
29 KiB
TypeScript
921 lines
29 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 { INode, IRunExecutionData, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow';
|
|
import { createRunExecutionData, NodeApiError, NodeOperationError } from 'n8n-workflow';
|
|
|
|
import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service';
|
|
import { WaitTracker } from '@/wait-tracker';
|
|
|
|
import { createExecution } from './shared/db/executions';
|
|
import {
|
|
createMember,
|
|
createMemberWithApiKey,
|
|
createOwner,
|
|
createOwnerWithApiKey,
|
|
} from './shared/db/users';
|
|
import { setupTestServer } from './shared/utils';
|
|
|
|
mockInstance(WaitTracker);
|
|
mockInstance(ConcurrencyControlService, {
|
|
// @ts-expect-error Private property
|
|
isEnabled: false,
|
|
});
|
|
|
|
const testServer = setupTestServer({
|
|
endpointGroups: ['executions', 'workflows', 'publicApi'],
|
|
modules: ['redaction'],
|
|
});
|
|
|
|
let owner: User;
|
|
let member: User;
|
|
let publicApiOwner: User;
|
|
let publicApiMember: User;
|
|
|
|
beforeEach(async () => {
|
|
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity', 'SharedWorkflow']);
|
|
testServer.license.reset();
|
|
testServer.license.enable('feat:dataRedaction');
|
|
owner = await createOwner();
|
|
member = await createMember();
|
|
publicApiOwner = await createOwnerWithApiKey();
|
|
publicApiMember = await createMemberWithApiKey();
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 with structured error', 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',
|
|
});
|
|
|
|
const response = await testServer
|
|
.authAgentFor(member)
|
|
.get(`/executions/${execution.id}`)
|
|
.query({ redactExecutionData: 'false' })
|
|
.expect(403);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.message).toContain('execution:reveal');
|
|
});
|
|
|
|
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));
|
|
});
|
|
});
|
|
|
|
describe('error redaction through DB round-trip', () => {
|
|
const mockNode: INode = {
|
|
id: 'node-1',
|
|
name: 'Test Node',
|
|
type: 'n8n-nodes-base.httpRequest',
|
|
typeVersion: 1,
|
|
position: [0, 0],
|
|
parameters: {},
|
|
};
|
|
|
|
test('task-level NodeApiError is redacted after DB round-trip', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const error = new NodeApiError(mockNode, { message: 'Bad Gateway' }, { httpCode: '502' });
|
|
const runData = buildRunExecutionData({ policy: 'all', mode: 'trigger' });
|
|
runData.resultData.runData['Test Node'][0].error = error;
|
|
|
|
const execution = await createExecution(
|
|
{ data: stringify(runData), mode: 'trigger' },
|
|
workflow,
|
|
);
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
const data = parseResponseData(response.body);
|
|
const taskData = data.resultData.runData['Test Node'][0];
|
|
expect(taskData.error).toBeUndefined();
|
|
expect(taskData.redactedError).toEqual({ type: 'NodeApiError', httpCode: null });
|
|
});
|
|
|
|
test('workflow-level NodeOperationError is redacted after DB round-trip', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
const error = new NodeOperationError(mockNode, 'Workflow operation failed');
|
|
const runData = buildRunExecutionData({ policy: 'all', mode: 'trigger' });
|
|
runData.resultData.error = error;
|
|
|
|
const execution = await createExecution(
|
|
{ data: stringify(runData), mode: 'trigger' },
|
|
workflow,
|
|
);
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
const data = parseResponseData(response.body);
|
|
expect(data.resultData.error).toBeUndefined();
|
|
expect(data.resultData.redactedError).toEqual({ type: 'NodeOperationError' });
|
|
expect(data.resultData.redactedError).not.toHaveProperty('httpCode');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /workflows/:workflowId/executions/last-successful — Execution Redaction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /workflows/:workflowId/executions/last-successful — Execution Redaction', () => {
|
|
describe('policy-based redaction (no query param)', () => {
|
|
test('policy "none" with trigger mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'trigger', policy: 'none' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data.data as IRunExecutionData);
|
|
});
|
|
|
|
test('policy "non-manual" with trigger mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'trigger', policy: 'non-manual' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data.data as IRunExecutionData);
|
|
});
|
|
|
|
test('policy "non-manual" with manual mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'manual', policy: 'non-manual' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data.data as IRunExecutionData);
|
|
});
|
|
|
|
test('policy "all" with trigger mode — returns redacted', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'trigger', policy: 'all' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data.data as IRunExecutionData);
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=true query param', () => {
|
|
test('always redacts even when policy is "none"', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'trigger', policy: 'none' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.query({ redactExecutionData: 'true' })
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data.data as IRunExecutionData, 'user_requested');
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=false query param (reveal)', () => {
|
|
test('owner can reveal unredacted data when policy is "all"', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
await createExecutionWithRedaction({ workflow, mode: 'trigger', policy: 'all' });
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.query({ redactExecutionData: 'false' })
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data.data as IRunExecutionData);
|
|
});
|
|
});
|
|
|
|
test('returns null when no successful execution exists', async () => {
|
|
const workflow = await createWorkflow({}, owner);
|
|
|
|
const response = await testServer
|
|
.authAgentFor(owner)
|
|
.get(`/workflows/${workflow.id}/executions/last-successful`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/v1/executions/:id — Execution Redaction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/executions/:id — Execution Redaction', () => {
|
|
describe('policy-based redaction (includeData=true)', () => {
|
|
test('policy "none" with trigger mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data as IRunExecutionData);
|
|
});
|
|
|
|
test('policy "non-manual" with trigger mode — redacts for member (canReveal=false)', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data as IRunExecutionData, 'workflow_redaction_policy', false);
|
|
});
|
|
|
|
test('policy "non-manual" with trigger mode — redacts for owner (canReveal=true)', async () => {
|
|
const workflow = await createWorkflow({}, publicApiOwner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiOwner)
|
|
.get(`/executions/${execution.id}?includeData=true`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data as IRunExecutionData, 'workflow_redaction_policy', true);
|
|
});
|
|
|
|
test('policy "non-manual" with manual mode — returns unredacted', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'manual',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data as IRunExecutionData);
|
|
});
|
|
|
|
test('policy "all" with trigger mode — redacts for member (canReveal=false)', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data as IRunExecutionData, 'workflow_redaction_policy', false);
|
|
});
|
|
|
|
test('includeData=false — returns execution without data field', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}`)
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=true query param', () => {
|
|
test('always redacts even when policy is "none"', async () => {
|
|
const workflow = await createWorkflow({}, publicApiOwner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiOwner)
|
|
.get(`/executions/${execution.id}?includeData=true&redactExecutionData=true`)
|
|
.expect(200);
|
|
|
|
assertRedacted(response.body.data as IRunExecutionData, 'user_requested');
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=false query param (reveal)', () => {
|
|
test('owner can reveal unredacted data', async () => {
|
|
const workflow = await createWorkflow({}, publicApiOwner);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiOwner)
|
|
.get(`/executions/${execution.id}?includeData=true&redactExecutionData=false`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data as IRunExecutionData);
|
|
});
|
|
|
|
test('member without execution:reveal scope gets 403', async () => {
|
|
testServer.license.enable('feat:sharing');
|
|
|
|
const teamProject = await createTeamProject();
|
|
await linkUserToProject(publicApiMember, teamProject, 'project:editor');
|
|
|
|
const workflow = await createWorkflow({}, teamProject);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true&redactExecutionData=false`)
|
|
.expect(403);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.message).toContain('execution:reveal');
|
|
});
|
|
|
|
test('member 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(publicApiMember, teamProject, 'project:editor');
|
|
|
|
const workflow = await createWorkflow({}, teamProject);
|
|
const execution = await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get(`/executions/${execution.id}?includeData=true&redactExecutionData=false`)
|
|
.expect(200);
|
|
|
|
assertNotRedacted(response.body.data as IRunExecutionData);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/v1/executions — Execution Redaction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/v1/executions — Execution Redaction', () => {
|
|
test('policy "non-manual" with trigger mode — redacts all executions in list', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'non-manual',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get('/executions?includeData=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1);
|
|
assertRedacted(
|
|
response.body.data[0].data as IRunExecutionData,
|
|
'workflow_redaction_policy',
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('policy "none" — list returns unredacted when includeData=true', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get('/executions?includeData=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1);
|
|
assertNotRedacted(response.body.data[0].data as IRunExecutionData);
|
|
});
|
|
|
|
test('includeData=false — returns executions without data field', async () => {
|
|
const workflow = await createWorkflow({}, publicApiMember);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get('/executions')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1);
|
|
expect(response.body.data[0].data).toBeUndefined();
|
|
});
|
|
|
|
test('batch: multiple executions across multiple workflows — each redacted independently', async () => {
|
|
const workflow1 = await createWorkflow({}, publicApiMember);
|
|
const workflow2 = await createWorkflow({}, publicApiMember);
|
|
|
|
// workflow1: policy "non-manual", trigger → should be redacted
|
|
await createExecutionWithRedaction({
|
|
workflow: workflow1,
|
|
mode: 'trigger',
|
|
policy: 'non-manual',
|
|
});
|
|
// workflow1: policy "non-manual", manual → should NOT be redacted
|
|
await createExecutionWithRedaction({
|
|
workflow: workflow1,
|
|
mode: 'manual',
|
|
policy: 'non-manual',
|
|
});
|
|
// workflow2: policy "none", trigger → should NOT be redacted
|
|
await createExecutionWithRedaction({ workflow: workflow2, mode: 'trigger', policy: 'none' });
|
|
// workflow2: policy "all", webhook → should be redacted
|
|
await createExecutionWithRedaction({ workflow: workflow2, mode: 'webhook', policy: 'all' });
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get('/executions?includeData=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(4);
|
|
|
|
const results = response.body.data as Array<{
|
|
workflowId: string;
|
|
mode: string;
|
|
data: IRunExecutionData;
|
|
}>;
|
|
|
|
const wf1Trigger = results.find((e) => e.workflowId === workflow1.id && e.mode === 'trigger');
|
|
const wf1Manual = results.find((e) => e.workflowId === workflow1.id && e.mode === 'manual');
|
|
const wf2Trigger = results.find((e) => e.workflowId === workflow2.id && e.mode === 'trigger');
|
|
const wf2Webhook = results.find((e) => e.workflowId === workflow2.id && e.mode === 'webhook');
|
|
|
|
assertRedacted(wf1Trigger!.data, 'workflow_redaction_policy', false);
|
|
assertNotRedacted(wf1Manual!.data);
|
|
assertNotRedacted(wf2Trigger!.data);
|
|
assertRedacted(wf2Webhook!.data, 'workflow_redaction_policy', false);
|
|
});
|
|
|
|
describe('explicit redactExecutionData=true query param', () => {
|
|
test('always redacts even when policy is "none"', async () => {
|
|
const workflow = await createWorkflow({}, publicApiOwner);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'none',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiOwner)
|
|
.get('/executions?includeData=true&redactExecutionData=true')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1);
|
|
assertRedacted(response.body.data[0].data as IRunExecutionData, 'user_requested');
|
|
});
|
|
});
|
|
|
|
describe('explicit redactExecutionData=false query param (reveal)', () => {
|
|
test('owner can reveal unredacted data in list', async () => {
|
|
const workflow = await createWorkflow({}, publicApiOwner);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiOwner)
|
|
.get('/executions?includeData=true&redactExecutionData=false')
|
|
.expect(200);
|
|
|
|
expect(response.body.data).toHaveLength(1);
|
|
assertNotRedacted(response.body.data[0].data as IRunExecutionData);
|
|
});
|
|
|
|
test('member without execution:reveal scope gets 403 in list', async () => {
|
|
testServer.license.enable('feat:sharing');
|
|
|
|
const teamProject = await createTeamProject();
|
|
await linkUserToProject(publicApiMember, teamProject, 'project:editor');
|
|
|
|
const workflow = await createWorkflow({}, teamProject);
|
|
await createExecutionWithRedaction({
|
|
workflow,
|
|
mode: 'trigger',
|
|
policy: 'all',
|
|
});
|
|
|
|
const response = await testServer
|
|
.publicApiAgentFor(publicApiMember)
|
|
.get('/executions?includeData=true&redactExecutionData=false')
|
|
.expect(403);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body.message).toContain('execution:reveal');
|
|
});
|
|
});
|
|
});
|