test: Add E2E Playwright coverage for redaction enforcement (#31963)

This commit is contained in:
Ilfat Mindubaev
2026-06-11 14:14:50 +03:00
committed by GitHub
parent 55dc1690a0
commit 03aca697df
10 changed files with 501 additions and 1 deletions
@@ -105,6 +105,16 @@ export class NavigationHelper {
await this.page.goto(url);
}
/**
* Navigate to a specific execution within a workflow
* URLs:
* - Existing workflow: /workflow/{workflowId}/executions/{executionId}
*/
async toExecution(workflowId: string, executionId: string): Promise<void> {
const url = `/workflow/${workflowId}/executions/${executionId}`;
await this.page.goto(url);
}
/**
* Navigate to a specific folder
* URL: /projects/{projectId}/folders/{folderId}/workflows or /home/folders/{folderId}/workflows
@@ -2,6 +2,7 @@ import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { LogsPanel } from './components/LogsPanel';
import { RunDataPanel } from './components/RunDataPanel';
export class ExecutionsPage extends BasePage {
async goto(projectId?: string) {
@@ -10,6 +11,7 @@ export class ExecutionsPage extends BasePage {
}
readonly logsPanel = new LogsPanel(this.getPreviewIframe().getByTestId('logs-panel'));
readonly outputPanel = new RunDataPanel(this.getPreviewIframe().getByTestId('output-panel'));
async clickDebugInEditorButton(): Promise<void> {
await this.clickButtonByName('Debug in editor');
@@ -113,6 +115,12 @@ export class ExecutionsPage extends BasePage {
await this.getFilterButton().click();
}
async openNodeExecutionDetails(name: string): Promise<void> {
await this.getPreviewIframe()
.locator(`[data-test-id="canvas-node"][data-node-name="${name}"]`)
.dblclick();
}
getFilterBadge(): Locator {
return this.page.getByTestId('execution-filter-badge');
}
@@ -0,0 +1,52 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
type RedactionScope = 'production' | 'all';
const SCOPE_OPTION_LABEL: Record<RedactionScope, string> = {
production: 'Production executions (Recommended)',
all: 'Manual and production executions',
};
export class SecuritySettingsPage extends BasePage {
async goto() {
// Wait for the settings to load so all components are available. Attach the
// listener before navigating, or the response can land before we wait for it.
await Promise.all([
this.waitForRestResponse('/rest/settings/security', 'GET'),
this.page.goto('/settings/security'),
]);
}
getEnforcementToggle(): Locator {
return this.page.getByTestId('enable-redaction-enforcement');
}
getEnforcementScopeSelect(): Locator {
return this.page.getByTestId('redaction-enforcement-scope-select');
}
getEnforcementSummary(): Locator {
return this.page.getByTestId('redaction-enforcement-summary');
}
private getConfirmDialog(): Locator {
return this.page.getByRole('dialog');
}
async enableEnforcement(): Promise<void> {
await this.getEnforcementToggle().click();
await this.getConfirmDialog().getByRole('button', { name: 'Enable' }).click();
}
async disableEnforcement(): Promise<void> {
await this.getEnforcementToggle().click();
await this.getConfirmDialog().getByRole('button', { name: 'Disable' }).click();
}
async selectScope(scope: RedactionScope): Promise<void> {
await this.getEnforcementScopeSelect().click();
await this.page.getByRole('option', { name: SCOPE_OPTION_LABEL[scope] }).click();
}
}
@@ -91,8 +91,50 @@ export class WorkflowSettingsModal extends BasePage {
await this.getUnpublishModal().getByRole('button', { name: 'Unpublish' }).click();
}
getRedactionPolicyRow(): Locator {
return this.container.getByTestId('workflow-settings-redaction-policy');
}
getRedactProductionSelect(): Locator {
return this.container.getByTestId('workflow-settings-redact-production-select');
}
getRedactManualSelect(): Locator {
return this.container.getByTestId('workflow-settings-redact-manual-select');
}
getRedactProductionInput(): Locator {
return this.getRedactProductionSelect().locator('input');
}
getRedactManualInput(): Locator {
return this.getRedactManualSelect().locator('input');
}
async hoverRedactProductionSelect(): Promise<void> {
await this.getRedactProductionSelect().hover();
}
async hoverRedactManualSelect(): Promise<void> {
await this.getRedactManualSelect().hover();
}
async selectProductionRedactMode(mode: string): Promise<void> {
await this.getRedactProductionSelect().click();
await this.page.getByRole('option', { name: mode, exact: true }).click();
}
async selectManualRedactMode(mode: string): Promise<void> {
await this.getRedactManualSelect().click();
await this.page.getByRole('option', { name: mode, exact: true }).click();
}
getTooltip(): Locator {
return this.page.getByTestId('tooltip-content');
}
getSaveButton(): Locator {
return this.container.getByRole('button', { name: 'Save' });
return this.page.getByTestId('workflow-settings-save-button').getByRole('button');
}
getDuplicateModal(): Locator {
@@ -114,6 +156,12 @@ export class WorkflowSettingsModal extends BasePage {
async open(): Promise<void> {
await this.getWorkflowMenu().click();
await this.getSettingsMenuItem().click();
await this.waitUntilLoaded();
}
private async waitUntilLoaded(): Promise<void> {
// `v-loading` directive's class
await this.container.locator('.el-loading-mask').waitFor({ state: 'detached' });
}
async clickSave(): Promise<void> {
@@ -31,6 +31,7 @@ import { NpsSurveyPage } from './NpsSurveyPage';
import { OAuthConsentPage } from './OAuthConsentPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SecretsProviderSettingsPage } from './SecretsProviderSettingsPage';
import { SecuritySettingsPage } from './SecuritySettingsPage';
import { SettingsEnvironmentPage } from './SettingsEnvironmentPage';
import { SettingsLogStreamingPage } from './SettingsLogStreamingPage';
import { SettingsPersonalPage } from './SettingsPersonalPage';
@@ -115,6 +116,7 @@ export class n8nPage {
readonly settingsEnvironment: SettingsEnvironmentPage;
readonly secretsProviderSettings: SecretsProviderSettingsPage;
readonly securitySettings: SecuritySettingsPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
@@ -189,6 +191,7 @@ export class n8nPage {
this.dataTableDetails = new DataTableDetails(page);
this.settingsEnvironment = new SettingsEnvironmentPage(page);
this.secretsProviderSettings = new SecretsProviderSettingsPage(page);
this.securitySettings = new SecuritySettingsPage(page);
this.settingsUsers = new SettingsUsersPage(page);
this.settingsSso = new SettingsSsoPage(page);
@@ -24,6 +24,7 @@ import { McpOAuthApiHelper } from './mcp-oauth-api-helper';
import { ProjectApiHelper } from './project-api-helper';
import { PublicApiHelper } from './public-api-helper';
import { RoleApiHelper } from './role-api-helper';
import { SecuritySettingsApiHelper } from './security-settings-api-helper';
import { SourceControlApiHelper } from './source-control-api-helper';
import { TagApiHelper } from './tag-api-helper';
import { UserApiHelper, type TestUser } from './user-api-helper';
@@ -78,6 +79,7 @@ export class ApiHelpers {
tags: TagApiHelper;
roles: RoleApiHelper;
sourceControl: SourceControlApiHelper;
securitySettings: SecuritySettingsApiHelper;
publicApi: PublicApiHelper;
@@ -96,6 +98,7 @@ export class ApiHelpers {
this.tags = new TagApiHelper(this);
this.roles = new RoleApiHelper(this);
this.sourceControl = new SourceControlApiHelper(this);
this.securitySettings = new SecuritySettingsApiHelper(this);
this.publicApi = new PublicApiHelper(this);
}
@@ -0,0 +1,43 @@
import type { RedactionFloor } from '@n8n/api-types';
import type { ApiHelpers } from './api-helper';
import { TestError } from '../Types';
/**
* Helpers for the instance Security & Policies settings, focused on the
* redaction-enforcement floor (`off` / `production` / `all`).
*
* Endpoints:
* - GET /rest/settings/security
* - POST /rest/settings/security
*
* Both are gated by the `feat:personalSpacePolicy` license and the
* `securitySettings:manage` global scope, so enable that license before use.
*/
export class SecuritySettingsApiHelper {
constructor(private readonly api: ApiHelpers) {}
async getRedactionFloor(): Promise<RedactionFloor | undefined> {
const response = await this.api.request.get('/rest/settings/security');
if (!response.ok()) {
throw new TestError(
`GET /rest/settings/security failed (${response.status()}): ${await response.text()}`,
);
}
const result = await response.json();
const settings = result.data ?? result;
return settings.redactionEnforcement?.floor;
}
async setRedactionFloor(floor: RedactionFloor): Promise<void> {
const response = await this.api.request.post('/rest/settings/security', {
data: { redactionEnforcement: { floor } },
});
if (!response.ok()) {
throw new TestError(
`POST /rest/settings/security failed (${response.status()}): ${await response.text()}`,
);
}
}
}
@@ -1,3 +1,4 @@
import type { APIResponse } from '@playwright/test';
import { readFileSync } from 'fs';
import type { IWorkflowBase, ExecutionSummary } from 'n8n-workflow';
import { nanoid } from 'nanoid';
@@ -118,6 +119,21 @@ export class WorkflowApiHelper {
return result.data ?? result;
}
/**
* Like {@link update}, but returns the raw response instead of throwing on a
* non-2xx status — for asserting a specific status code (e.g. the `422` from
* the redaction floor-enforcement guard).
*/
async updateRaw(
workflowId: string,
versionId: string,
data: Partial<IWorkflowBase>,
): Promise<APIResponse> {
return await this.api.request.patch(`/rest/workflows/${workflowId}`, {
data: { ...data, versionId },
});
}
async deactivate(workflowId: string) {
const response = await this.api.request.post(`/rest/workflows/${workflowId}/deactivate`);
@@ -0,0 +1,258 @@
import type { IWorkflowBase } from 'n8n-workflow';
import { manualWorkflow, REDACT_OPTION, webhookWorkflow } from './redaction-helpers';
import { expect, test } from '../../../fixtures/base';
import type { ApiHelpers } from '../../../services/api-helper';
test.use({
capability: {
env: {
TEST_ISOLATION: 'redaction-enforcement',
},
},
});
async function runProductionExecution(
api: ApiHelpers,
settings?: Partial<IWorkflowBase['settings']>,
) {
const { workflowId, webhookPath, createdWorkflow } =
await api.workflows.createWorkflowFromDefinition(webhookWorkflow(settings));
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
await api.webhooks.trigger(`/webhook/${webhookPath}`, { maxNotFoundRetries: 5 });
const summary = await api.workflows.waitForExecution(workflowId, 15_000, 'webhook');
return { executionId: summary.id, workflowId, workflow: createdWorkflow };
}
async function runManualExecution(api: ApiHelpers, settings?: Partial<IWorkflowBase['settings']>) {
const createdWorkflow = await api.workflows.createWorkflow(manualWorkflow(settings));
const result = await api.workflows.runManually(createdWorkflow.id, createdWorkflow.nodes[0].name);
return {
workflowId: createdWorkflow.id,
executionId: result.executionId,
workflow: createdWorkflow,
};
}
test.describe(
'Redaction enforcement',
{ annotation: [{ type: 'owner', description: 'Enterprise Node & Partnerships' }] },
() => {
// The redaction floor is a single instance-global value, so these tests cannot
// run in parallel against the shared instance without racing on it. Force serial
// execution; each test then sets the floor it needs from a clean baseline.
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ api }) => {
await api.enableFeature('personalSpacePolicy');
await api.enableFeature('dataRedaction');
// Reset the instance-global floor to a known-clean value before each test.
await api.securitySettings.setRedactionFloor('off');
});
test('can be enabled and scoped from the Security & Policies UI', async ({ n8n }) => {
await n8n.securitySettings.goto();
await n8n.securitySettings.enableEnforcement();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Data redaction enforced'),
).toBeVisible();
await expect(n8n.securitySettings.getEnforcementSummary()).toContainText(
'Production executions',
);
expect(await n8n.api.securitySettings.getRedactionFloor()).toBe('production');
await n8n.securitySettings.selectScope('all');
await expect(
n8n.notifications.getNotificationByTitleOrContent('Redaction scope updated'),
).toBeVisible();
await expect(n8n.securitySettings.getEnforcementSummary()).toContainText(
'Manual and production executions',
);
expect(await n8n.api.securitySettings.getRedactionFloor()).toBe('all');
});
test.describe('when floor is "production"', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.securitySettings.setRedactionFloor('production');
});
test('locks only the production select in the workflow settings', async ({ n8n }) => {
const workflow = await n8n.api.workflows.createWorkflow(webhookWorkflow());
await n8n.navigate.toWorkflow(workflow.id);
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getRedactProductionInput()).toHaveValue('Redact');
await expect(n8n.workflowSettingsModal.getRedactProductionInput()).toBeDisabled();
await n8n.workflowSettingsModal.hoverRedactProductionSelect();
await expect(n8n.workflowSettingsModal.getTooltip()).toHaveText(
/This option is enforced by your instance's redaction policy./,
);
await expect(n8n.workflowSettingsModal.getRedactManualInput()).toBeEnabled();
});
test('lets set stricter policy in the workflow settings', async ({ n8n }) => {
const workflow = await n8n.api.workflows.createWorkflow(webhookWorkflow());
await n8n.navigate.toWorkflow(workflow.id);
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getRedactManualInput()).toBeEnabled();
await n8n.workflowSettingsModal.selectManualRedactMode(REDACT_OPTION.redact);
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.workflowSettingsModal.getModal()).toBeHidden();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Workflow settings saved'),
).toBeVisible();
const saved = await n8n.api.workflows.getWorkflow(workflow.id);
expect(saved.settings?.redactionPolicy).toBe('all');
});
test('redacts production executions', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runProductionExecution(api);
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/Output data redacted/,
);
});
test('leaves manual executions unredacted', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runManualExecution(api);
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/This is an item, but it's empty/,
);
await expect(n8n.executions.outputPanel.getDataContainer()).not.toHaveText(
/Output data redacted/,
);
});
test('rejects workflow redaction changes that fall below the floor', async ({ api }) => {
const workflow = await api.workflows.createWorkflow(webhookWorkflow());
const current = await api.workflows.getWorkflow(workflow.id);
const rejected = await api.workflows.updateRaw(workflow.id, current.versionId!, {
settings: { ...current.settings, redactionPolicy: 'none' },
});
expect(rejected.status()).toBe(422);
const refreshed = await api.workflows.getWorkflow(workflow.id);
const accepted = await api.workflows.updateRaw(workflow.id, refreshed.versionId!, {
settings: { ...refreshed.settings, redactionPolicy: 'all' },
});
expect(accepted.ok()).toBe(true);
});
});
test.describe('when floor is "all"', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.securitySettings.setRedactionFloor('all');
});
test('locks both selects in the workflow settings', async ({ n8n }) => {
const workflow = await n8n.api.workflows.createWorkflow(webhookWorkflow());
await n8n.navigate.toWorkflow(workflow.id);
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getRedactProductionInput()).toHaveValue('Redact');
await expect(n8n.workflowSettingsModal.getRedactManualInput()).toHaveValue('Redact');
await expect(n8n.workflowSettingsModal.getRedactProductionInput()).toBeDisabled();
await expect(n8n.workflowSettingsModal.getRedactManualInput()).toBeDisabled();
await n8n.workflowSettingsModal.hoverRedactProductionSelect();
await expect(n8n.workflowSettingsModal.getTooltip()).toHaveText(
/This option is enforced by your instance's redaction policy./,
);
await n8n.workflowSettingsModal.hoverRedactManualSelect();
await expect(n8n.workflowSettingsModal.getTooltip()).toHaveText(
/This option is enforced by your instance's redaction policy./,
);
});
test('redacts production executions', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runProductionExecution(api);
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/Output data redacted/,
);
});
test('redacts manual executions', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runManualExecution(api);
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/Output data redacted/,
);
});
});
test.describe('when floor is "off" and workflow redaction is "non-manual"', () => {
test('redacts production executions', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runProductionExecution(api, {
redactionPolicy: 'non-manual',
});
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/Output data redacted/,
);
});
test('leaves manual executions unredacted', async ({ api, n8n }) => {
const { executionId, workflowId, workflow } = await runManualExecution(api, {
redactionPolicy: 'non-manual',
});
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/This is an item, but it's empty/,
);
await expect(n8n.executions.outputPanel.getDataContainer()).not.toHaveText(
/Output data redacted/,
);
});
});
test('does not retroactively redact executions captured before the floor was raised', async ({
api,
n8n,
}) => {
const { executionId, workflowId, workflow } = await runProductionExecution(api);
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/executionMode[\s\S]*production/i,
);
await expect(n8n.executions.outputPanel.getDataContainer()).not.toHaveText(
/Output data redacted/,
);
await api.securitySettings.setRedactionFloor('all');
await n8n.navigate.toExecution(workflowId, executionId);
await n8n.executions.openNodeExecutionDetails(workflow.nodes[0].name);
await expect(n8n.executions.outputPanel.getDataContainer()).toHaveText(
/executionMode[\s\S]*production/i,
);
await expect(n8n.executions.outputPanel.getDataContainer()).not.toHaveText(
/Output data redacted/,
);
});
},
);
@@ -0,0 +1,59 @@
import type { IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
export const REDACT_OPTION = {
redact: 'Redact',
default: 'Default - Do not redact',
} as const;
const SAVE_ALL_SETTINGS = {
saveManualExecutions: true,
saveDataSuccessExecution: 'all',
saveDataErrorExecution: 'all',
} as const;
export function webhookWorkflow(
settings: Partial<IWorkflowBase['settings']> = {},
): Partial<IWorkflowBase> {
const webhookId = nanoid();
return {
name: `Redaction ${nanoid(8)}`,
nodes: [
{
id: nanoid(),
name: 'Webhook',
webhookId,
parameters: {
path: webhookId,
options: {},
},
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [0, 0] as [number, number],
},
],
connections: {},
settings: { ...SAVE_ALL_SETTINGS, ...settings },
};
}
export function manualWorkflow(
settings: Partial<IWorkflowBase['settings']> = {},
): Partial<IWorkflowBase> {
return {
name: `Manual ${nanoid(8)}`,
nodes: [
{
id: nanoid(),
name: 'Manual',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
},
],
connections: {},
settings: { ...SAVE_ALL_SETTINGS, ...settings },
};
}