test: Migrate sharing spec from Cypress to Playwright (#21024)

This commit is contained in:
Artem Sorokin 2025-10-27 13:16:55 +01:00 committed by GitHub
parent 3caa5ac3a5
commit e3bb2f5c68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 674 additions and 835 deletions

View File

@ -1,127 +0,0 @@
name: Reusable e2e workflow
on:
workflow_call:
inputs:
branch:
description: 'GitHub branch to test.'
required: false
type: string
user:
description: 'User who kicked this off.'
required: false
type: string
default: 'schedule'
spec:
description: 'Specify specs.'
required: false
default: 'e2e/*'
type: string
parallel:
description: 'Run tests in parallel.'
required: false
default: true
type: boolean
containers:
description: 'Number of containers to run tests in.'
required: false
default: '[1, 2]'
type: string
pr_number:
description: 'PR number to run tests for.'
required: false
type: number
secrets:
CURRENTS_RECORD_KEY:
description: 'Currents record key.'
required: true
env:
NODE_OPTIONS: --max-old-space-size=3072
jobs:
testing:
runs-on: blacksmith-2vcpu-ubuntu-2204
outputs:
dashboardUrl: ${{ steps.cypress.outputs.dashboardUrl }}
strategy:
fail-fast: false
matrix:
# If spec is not e2e/* then we run only one container to prevent
# running the same tests multiple times
containers: ${{ fromJSON( inputs.spec == 'e2e/*' && inputs.containers || '[1]' ) }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.branch || github.ref }}
- name: Set up and build
uses: ./.github/actions/setup-nodejs-blacksmith
- name: Install Cypress
working-directory: cypress
run: pnpm cypress:install
- name: Cypress run
id: cypress
uses: cypress-io/github-action@be1bab96b388bbd9ce3887e397d373c8557e15af # v6.9.2
with:
working-directory: cypress
install: false
start: pnpm start
wait-on: 'http://localhost:5678'
wait-on-timeout: 120
record: false
spec: ${{ inputs.spec == 'e2e/*' && format('e2e/group{0}/**/*.cy.ts', matrix.containers) || inputs.spec }}
env:
NODE_OPTIONS: --dns-result-order=ipv4first
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true
COMMIT_INFO_MESSAGE: 🌳 ${{ inputs.branch }} 🤖 ${{ inputs.user }} 🗃️ ${{ inputs.spec }}
SHELL: /bin/sh
- name: Upload test results artifact
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: test-results-${{ matrix.containers }}
path: cypress/test-results-*.xml
upload-to-currents:
needs: testing
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download all test results
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: test-results
- name: Merge and upload to Currents
run: |
npm install -g @currents/cmd junit-report-merger
# Merge all XML files, so Currents can show a single view for Cypress
jrm combined-results.xml "test-results/**/test-results-*.xml"
- name: Upload merged XML as artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: merged-junit-results
path: combined-results.xml
- name: Convert and upload to Currents
run: |
currents convert \
--input-format=junit \
--input-file=combined-results.xml \
--output-dir=.currents \
--framework=node \
--framework-version=cypress-14.4.0
currents upload \
--project-id=I0yzoc \
--key=${{ secrets.CURRENTS_RECORD_KEY }} \
--ci-build-id=n8n-io/n8n-${{ github.run_id }}-${{ github.run_attempt }} \
--report-dir=.currents \
--tag=cypress

View File

@ -16,16 +16,6 @@ jobs:
with:
is_pr_approved_by_maintainer: true
run-e2e-tests:
name: E2E
uses: ./.github/workflows/e2e-reusable.yml
needs: [eligibility_check]
if: needs.eligibility_check.outputs.should_run == 'true'
with:
pr_number: ${{ github.event.pull_request.number }}
user: ${{ github.event.pull_request.user.login || 'PR User' }}
secrets: inherit
run-playwright-tests:
name: Playwright
uses: ./.github/workflows/playwright-test-reusable.yml
@ -36,9 +26,9 @@ jobs:
post-e2e-tests:
name: E2E - Checks
runs-on: ubuntu-latest
needs: [eligibility_check, run-e2e-tests, run-playwright-tests]
needs: [eligibility_check, run-playwright-tests]
if: always() && needs.eligibility_check.result != 'skipped'
steps:
- name: Fail if tests failed
if: needs.run-e2e-tests.result == 'failure' || needs.run-playwright-tests.result == 'failure'
if: needs.run-playwright-tests.result == 'failure'
run: exit 1

View File

@ -10,11 +10,6 @@ on:
description: 'GitHub branch to test.'
required: false
default: 'master'
spec:
description: 'Specify specs.'
required: false
default: 'e2e/*'
type: string
user:
description: 'User who kicked this off.'
required: false
@ -41,15 +36,6 @@ jobs:
[[ "${{ env.START_URL }}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' "${{ env.START_URL }}" || echo ""
shell: bash
run-e2e-tests:
name: E2E
uses: ./.github/workflows/e2e-reusable.yml
with:
branch: ${{ github.event.inputs.branch || 'master' }}
user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
secrets: inherit
run-playwright-tests:
name: Playwright
uses: ./.github/workflows/playwright-test-reusable.yml
@ -60,7 +46,7 @@ jobs:
calls-success-url-notify:
name: Calls success URL and notifies
runs-on: ubuntu-latest
needs: [run-e2e-tests, run-playwright-tests]
needs: [run-playwright-tests]
if: ${{ github.event.inputs.success-url != '' }}
steps:
- name: Notify Slack on failure

View File

@ -1,249 +0,0 @@
import {
addItemToFixedCollection,
assertNodeOutputHintExists,
clickExecuteNode,
clickGetBackToCanvas,
getExecuteNodeButton,
getOutputTableHeaders,
getParameterInputByName,
populateFixedCollection,
selectResourceLocatorItem,
selectResourceLocatorAddResourceItem,
typeIntoFixedCollectionItem,
clickWorkflowCardContent,
assertOutputTableContent,
populateMapperFields,
getNodeRunInfoStale,
assertNodeOutputErrorMessageExists,
checkParameterCheckboxInputByName,
uncheckParameterCheckboxInputByName,
} from '../../composables/ndv';
import {
clickExecuteWorkflowButton,
clickZoomToFit,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../../composables/workflow';
import { visitWorkflowsPage } from '../../composables/workflowsPage';
import SUB_WORKFLOW_INPUTS from '../../fixtures/Test_Subworkflow-Inputs.json';
import { errorToast, successToast } from '../../pages/notifications';
import { getVisiblePopper } from '../../utils';
const DEFAULT_WORKFLOW_NAME = 'My workflow';
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
const EXAMPLE_FIELDS = [
['aNumber', 'Number'],
['aString', 'String'],
['aArray', 'Array'],
['aObject', 'Object'],
['aAny', 'Allow Any Type'],
// bool last because it's a switch instead of a normal inputField so we'll skip it for some cases
['aBool', 'Boolean'],
] as const;
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
let openedUrl = '';
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
openedUrl = url;
});
});
selectResourceLocatorAddResourceItem('workflowId', 'Create a');
cy.then(() => cy.visit(openedUrl));
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
// Close NDV before opening the node creator
clickGetBackToCanvas();
openNode('When Executed by Another Workflow');
});
it('works with type-checked values', () => {
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...EXAMPLE_FIELDS.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // the `}}` at the end are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values.map((x, i) => [EXAMPLE_FIELDS[i][0], x]));
clickExecuteNode();
const expected = [
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected);
// Test the type-checking options
populateMapperFields([['aString', '{selectAll}{backspace}{{}{{} 5']]);
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
// attemptToConvertTypes enabled
checkParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
const expected2 = [
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected2);
// disabled again
uncheckParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
});
it('works with Fields input source, and can then be changed to JSON input source', () => {
assertNodeOutputHintExists();
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
selectResourceLocatorAddResourceItem('workflowId', 'Create a');
openNode('When Executed by Another Workflow');
getParameterInputByName('inputSource').click();
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + EXAMPLE_FIELDS.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
getParameterInputByName('jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
clickExecuteNode();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
EXAMPLE_FIELDS.map((f) => f[0]),
);
assertOutputTableContent([
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
]);
clickExecuteNode();
});
});
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should show node issue when no fields are defined in manual mode', () => {
getExecuteNodeButton().should('be.disabled');
clickGetBackToCanvas();
// Executing the workflow should show an error toast
clickExecuteWorkflowButton();
errorToast().should('contain', 'The workflow has issues');
openNode('When Executed by Another Workflow');
// Add a field to the workflowInputs fixedCollection
addItemToFixedCollection('workflowInputs');
typeIntoFixedCollectionItem('workflowInputs', 0, 'test');
// Executing the workflow should not show error now
clickGetBackToCanvas();
clickExecuteWorkflowButton();
successToast().should('contain', 'Workflow executed successfully');
});
});
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates the outputPanel matches our changes
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
clickExecuteNode();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
visitWorkflowsPage();
clickWorkflowCardContent(DEFAULT_WORKFLOW_NAME);
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
selectResourceLocatorItem('workflowId', offset - 1, targetChild);
clickExecuteNode();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
}
function makeExample(type: TypeField) {
switch (type) {
case 'String':
return '"example"';
case 'Number':
return '42';
case 'Boolean':
return 'true';
case 'Array':
return '["example", 123, null]';
case 'Object':
return '{{}"example": [123]}';
case 'Allow Any Type':
return 'null';
}
}

View File

@ -1,428 +0,0 @@
import * as credentialsComposables from '../../composables/credentialsComposables';
import { saveCredential } from '../../composables/modals/credential-modal';
import * as projects from '../../composables/projects';
import {
INSTANCE_MEMBERS,
INSTANCE_OWNER,
INSTANCE_ADMIN,
NOTION_NODE_NAME,
} from '../../constants';
import {
CredentialsModal,
CredentialsPage,
NDV,
WorkflowPage,
WorkflowSharingModal,
WorkflowsPage,
} from '../../pages';
import { getVisibleDropdown, getVisiblePopper, getVisibleSelect } from '../../utils';
/**
* User U1 - Instance owner
* User U2 - User, owns C1, W1, W2
* User U3 - User, owns C2
*
* W1 - Workflow owned by User U2, shared with User U3
* W2 - Workflow owned by User U2
*
* C1 - Credential owned by User U2
* C2 - Credential owned by User U3, shared with User U1 and User U2
*/
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const workflowSharingModal = new WorkflowSharingModal();
const ndv = new NDV();
describe('Sharing', { disableAutoLogin: true }, () => {
before(() => cy.enableFeature('sharing'));
let workflowW2Url = '';
it('should create C1, W1, W2, share W1 with U3, as U2', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('Credential C1');
credentialsModal.actions.save();
credentialsModal.actions.close();
workflowPage.actions.visit();
workflowPage.actions.setWorkflowName('Workflow W1');
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('Notion', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C1');
ndv.actions.close();
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_MEMBERS[1].email);
workflowSharingModal.actions.save();
workflowPage.actions.saveWorkflowOnButtonClick();
cy.visit(workflowsPage.url);
workflowsPage.getters.createWorkflowButton().click();
cy.createFixtureWorkflow('Test_workflow_1.json');
workflowPage.actions.setWorkflowName('Workflow W2');
cy.url().then((url) => {
workflowW2Url = url;
});
});
it('should create C2, share C2 with U1 and U2, as U3', () => {
cy.signinAsMember(1);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Airtable Personal Access Token API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Access Token').type('1234567890');
credentialsModal.actions.setName('Credential C2');
credentialsModal.actions.changeTab('Sharing');
credentialsModal.actions.addUser(INSTANCE_OWNER.email);
credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
credentialsModal.actions.save();
credentialsModal.actions.close();
});
it('should open W1, add node using C2 as U3', () => {
cy.signinAsMember(1);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2');
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Append a block');
ndv.getters.credentialInput().should('have.value', 'Credential C1').should('be.disabled');
ndv.actions.close();
});
it('should open W1, add node using C2 as U2', () => {
cy.signinAsMember(0);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2');
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.openNode('Append a block');
ndv.getters
.credentialInput()
.find('input')
.should('have.value', 'Credential C1')
.should('be.enabled');
ndv.actions.close();
});
it('should not have access to W2, as U3', () => {
cy.signinAsMember(1);
cy.visit(workflowW2Url);
cy.waitForLoad();
cy.location('pathname', { timeout: 10000 }).should('eq', '/entity-not-authorized/workflow');
});
it('should have access to W1, W2, as U1', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardContent('Workflow W1').click();
workflowPage.actions.openNode('Append a block');
ndv.getters
.credentialInput()
.find('input')
.should('have.value', 'Credential C1')
.should('be.enabled');
ndv.actions.close();
cy.waitForLoad();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent('Workflow W2').click('top');
workflowPage.actions.executeWorkflow();
});
it('should automatically test C2 when opened by U2 sharee', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.credentialCard('Credential C2').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
});
it('should work for admin role on credentials created by others (also can share it with themselves)', () => {
cy.signinAsMember(0);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click({ force: true });
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('Credential C3');
credentialsModal.actions.save();
credentialsModal.actions.close();
cy.signout();
cy.signinAsAdmin();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.credentialCard('Credential C3').click();
credentialsModal.getters.testSuccessTag().should('be.visible');
cy.get('input').should('not.have.length');
credentialsModal.actions.changeTab('Sharing');
cy.contains(
'Sharing a credential allows people to use it in their workflows. They cannot access credential details.',
).should('be.visible');
credentialsModal.getters.usersSelect().click();
getVisiblePopper()
.find('[data-test-id="project-sharing-info"]')
.filter(':visible')
.should('have.length', 3)
.contains(INSTANCE_ADMIN.email)
.should('have.length', 1);
getVisibleSelect().contains(INSTANCE_OWNER.email.toLowerCase()).click();
credentialsModal.actions.addUser(INSTANCE_MEMBERS[1].email);
credentialsModal.actions.addUser(INSTANCE_ADMIN.email);
credentialsModal.actions.saveSharing();
credentialsModal.actions.close();
});
it('credentials should work between team and personal projects', () => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
cy.signinAsOwner();
cy.visit('/');
projects.createProject('Development');
projects.getHomeButton().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Test workflow');
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Notion API');
credentialsPage.getters.credentialCard('Notion API').click();
credentialsModal.actions.changeTab('Sharing');
credentialsModal.getters.usersSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 4)
.filter(':contains("Development")')
.should('have.length', 1)
.click();
saveCredential();
credentialsModal.actions.close();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.workflowCardActions('Test workflow').click();
getVisibleDropdown().find('li').contains('Share').click();
workflowSharingModal.getters.usersSelect().filter(':visible').click();
getVisibleSelect().find('li').should('have.length', 3).first().click();
workflowSharingModal.getters.saveButton().click();
projects.getMenuItems().first().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Test workflow 2');
workflowPage.actions.openShareModal();
workflowSharingModal.getters.usersSelect().should('not.exist');
cy.get('body').type('{esc}');
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.createCredentialButton().click();
projects.createCredential('Notion API 2', false);
credentialsModal.actions.changeTab('Sharing');
credentialsModal.getters.usersSelect().click();
getVisibleSelect().find('li').should('have.length', 4).first().click();
saveCredential();
credentialsModal.actions.close();
credentialsPage.getters
.credentialCards()
.should('have.length', 2)
.filter(':contains("Personal")')
.should('have.length', 1);
});
});
describe('Credential Usage in Cross Shared Workflows', () => {
beforeEach(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
cy.reload();
cy.signinAsOwner();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
});
it('should only show credentials from the same team project', () => {
// Create a notion credential in the home project
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in one project
projects.createProject('Development');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in another project
projects.createProject('Test');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a workflow with a notion node in the same project
projects.getProjectTabWorkflows().click();
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the credential in this project should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 1);
});
it('should only show credentials in their personal project for members', () => {
// Create a notion credential as the owner
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create another notion credential as the owner, but share it with member
// 0
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API', false);
credentialsModal.actions.changeTab('Sharing');
credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
credentialsModal.actions.saveSharing();
// As the member, create a new notion credential and a workflow
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2);
});
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
const workflowName = 'Test workflow';
// Create a notion credential as the owner and a workflow that is shared
// with member 0
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
//cy.visit(workflowsPage.url);
projects.getProjectTabWorkflows().click();
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.setWorkflowName(workflowName);
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_MEMBERS[0].email);
workflowSharingModal.actions.save();
// As the member, create a new notion credential
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 1);
});
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
const workflowName = 'Test workflow';
// As member 1, create a new notion credential. This should not show up.
cy.signinAsMember(1);
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As admin, create a new notion credential. This should show up.
cy.signinAsAdmin();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As member 0, create a new notion credential and a workflow and share it
// with the global owner and the admin.
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.setWorkflowName(workflowName);
workflowPage.actions.openShareModal();
workflowSharingModal.actions.addUser(INSTANCE_OWNER.email);
workflowSharingModal.actions.addUser(INSTANCE_ADMIN.email);
workflowSharingModal.actions.save();
// As the global owner, create a new notion credential and open the shared
// workflow
cy.signinAsOwner();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.createCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the personal credentials of the workflow owner and the global owner should show up.
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
});
it('should show all personal credentials if the global owner owns the workflow', () => {
// As member 0, create a new notion credential.
cy.signinAsMember();
credentialsComposables.loadCredentialsPage(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// As the global owner, create a workflow and add a notion node
cy.signinAsOwner();
cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Show all personal credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.have.length', 1);
});
});

View File

@ -1,5 +1,6 @@
import type { Request } from '@playwright/test';
import { expect } from '@playwright/test';
import type { IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import type { n8nPage } from '../pages/n8nPage';
@ -142,6 +143,19 @@ export class WorkflowComposer {
}
/**
* Get workflow by name via API
* @param workflowName - Name of the workflow to find
* @returns Workflow object with id, name, and other properties
*/
async getWorkflowByName(workflowName: string): Promise<IWorkflowBase> {
const response = await this.n8n.api.request.get('/rest/workflows', {
params: new URLSearchParams({ filter: JSON.stringify({ name: workflowName }) }),
});
const workflows = await response.json();
return workflows.data[0];
}
/**
* Moves a workflow to a different project or user.
* @param workflowName - The name of the workflow to move
* @param projectNameOrEmail - The destination project name or user email

View File

@ -0,0 +1,61 @@
{
"name": "Test workflow 1",
"nodes": [
{
"parameters": {},
"id": "a2f85497-260d-4489-a957-2b7d88e2f33d",
"name": "On clicking 'execute'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [220, 260]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
},
"id": "9493d278-1ede-47c9-bedf-92ac3a737c65",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [400, 260]
}
],
"pinData": {},
"connections": {
"On clicking 'execute'": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [[]]
}
},
"active": false,
"settings": {},
"hash": "a59c7b1c97b1741597afae0fcd43ebef",
"id": 3,
"meta": {
"instanceId": "a5280676597d00ecd0ea712da7f9cf2ce90174a791a309112731f6e44d162f35"
},
"tags": [
{
"name": "some-tag-1",
"createdAt": "2022-11-10T13:43:34.001Z",
"updatedAt": "2022-11-10T13:43:34.001Z",
"id": "6"
},
{
"name": "some-tag-2",
"createdAt": "2022-11-10T13:43:39.778Z",
"updatedAt": "2022-11-10T13:43:39.778Z",
"id": "7"
}
]
}

View File

@ -307,6 +307,11 @@ export class CanvasPage extends BasePage {
await responsePromise;
}
async openShareModal(): Promise<void> {
await this.clickByTestId('workflow-share-button');
await this.page.getByTestId('workflowShare-modal').waitFor({ state: 'visible' });
}
async clickZoomToFitButton(): Promise<void> {
await this.clickByTestId('zoom-to-fit');
}
@ -517,10 +522,18 @@ export class CanvasPage extends BasePage {
return this.page.getByTestId('node-creator-action-item');
}
nodeCreatorCategoryItem(categoryName: string): Locator {
return this.page.getByTestId('node-creator-category-item').getByText(categoryName);
}
nodeCreatorCategoryItems(): Locator {
return this.page.getByTestId('node-creator-category-item');
}
getFirstAction(): Locator {
return this.page.locator('[data-keyboard-nav-type="action"]').first();
}
selectedNodes(): Locator {
return this.page
.locator('[data-test-id="canvas-node"]')

View File

@ -9,6 +9,14 @@ export class WorkflowSharingModal extends BasePage {
await this.getModal().waitFor({ state: 'visible', timeout: 5000 });
}
getUsersSelect() {
return this.page.getByTestId('project-sharing-select').filter({ visible: true });
}
getVisibleDropdown() {
return this.page.locator('.el-select-dropdown:visible');
}
async addUser(email: string) {
await this.clickByTestId('project-sharing-select');
await this.page
@ -19,6 +27,7 @@ export class WorkflowSharingModal extends BasePage {
async save() {
await this.clickByTestId('workflow-sharing-modal-save-button');
await this.getModal().waitFor({ state: 'hidden' });
}
async close() {

View File

@ -96,6 +96,10 @@ export class CredentialModal {
return this.root.getByTestId('oauth-connect-success-banner');
}
getTestSuccessTag(): Locator {
return this.root.getByTestId('credentials-config-container-test-success');
}
async editCredential(): Promise<void> {
await this.root.page().getByTestId('credential-edit-button').click();
}
@ -125,4 +129,54 @@ export class CredentialModal {
getAuthTypeRadioButtons() {
return this.root.page().locator('label.el-radio');
}
async changeTab(tabName: 'Sharing'): Promise<void> {
await this.root.getByTestId('menu-item').filter({ hasText: tabName }).click();
}
/**
* Get the users select dropdown in the Sharing tab
*/
getUsersSelect(): Locator {
return this.root.getByTestId('project-sharing-select').filter({ visible: true });
}
/**
* Get the visible dropdown popper (for sharing dropdown interactions)
*/
getVisibleDropdown(): Locator {
return this.root.page().locator('.el-popper[aria-hidden="false"]');
}
/**
* Add a user to credential sharing
* @param email - User email to share with
*/
async addUserToSharing(email: string): Promise<void> {
await this.getUsersSelect().click();
await this.getVisibleDropdown().getByText(email.toLowerCase(), { exact: false }).click();
}
/**
* Save credential sharing (different from regular save - hits /share endpoint)
*/
async saveSharing(): Promise<void> {
const saveBtn = this.getSaveButton();
await saveBtn.click();
// Wait for share API call to complete
await this.root
.page()
.waitForResponse(
(response) =>
response.url().includes('/rest/credentials/') &&
response.url().includes('/share') &&
response.request().method() === 'PUT',
);
await saveBtn.getByText('Saved', { exact: true }).waitFor({
state: 'visible',
timeout: 3000,
});
}
}

View File

@ -0,0 +1,23 @@
import type { Page } from '@playwright/test';
/**
* ProjectTabs component - navigation tabs within a project view
* Mirrors the ProjectTabs.vue component in the frontend
*/
export class ProjectTabsComponent {
constructor(private readonly page: Page) {}
async clickCredentialsTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /credentials/i })
.click();
}
async clickWorkflowsTab() {
await this.page
.getByTestId('project-tabs')
.getByRole('link', { name: /workflows/i })
.click();
}
}

View File

@ -6,6 +6,7 @@ import { CanvasPage } from './CanvasPage';
import { CommunityNodesPage } from './CommunityNodesPage';
import { BaseModal } from './components/BaseModal';
import { Breadcrumbs } from './components/Breadcrumbs';
import { ProjectTabsComponent } from './components/ProjectTabsComponent';
import { ResourceMoveModal } from './components/ResourceMoveModal';
import { CredentialsPage } from './CredentialsPage';
import { DataTableDetails } from './DataTableDetails';
@ -85,6 +86,10 @@ export class n8nPage {
readonly signIn: SignInPage;
readonly settingsUsers: SettingsUsersPage;
// Components
readonly projectTabs: ProjectTabsComponent;
readonly settingsEnvironment: SettingsEnvironmentPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
@ -148,6 +153,10 @@ export class n8nPage {
this.settingsEnvironment = new SettingsEnvironmentPage(page);
this.settingsUsers = new SettingsUsersPage(page);
// Components
this.projectTabs = new ProjectTabsComponent(page);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowCredentialSetupModal = new WorkflowCredentialSetupModal(page);

View File

@ -18,6 +18,10 @@ const CONTAINER_ONLY = new RegExp(`@capability:(${CONTAINER_ONLY_TAGS.join('|')}
// In local run they are a "dependency" which means they will be skipped if earlier tests fail, not ideal but needed for isolation
const SERIAL_EXECUTION = /@db:reset/;
// Routes tests to isolated worker without triggering automatic database resets in fixtures
// Use when tests need worker isolation but have intentional state dependencies (e.g., serial tests sharing data)
const ISOLATED_ONLY = /@isolated/;
const CONTAINER_CONFIGS: Array<{ name: string; config: N8NConfig }> = [
{ name: 'standard', config: {} },
{ name: 'postgres', config: { postgres: true } },
@ -34,21 +38,23 @@ export function getProjects(): Project[] {
{
name: 'ui',
testDir: './tests/ui',
grepInvert: new RegExp([CONTAINER_ONLY.source, SERIAL_EXECUTION.source].join('|')),
grepInvert: new RegExp(
[CONTAINER_ONLY.source, SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|'),
),
fullyParallel: true,
use: { baseURL: process.env.N8N_BASE_URL },
},
{
name: 'ui:isolated',
testDir: './tests/ui',
grep: SERIAL_EXECUTION,
grep: new RegExp([SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|')),
workers: 1,
use: { baseURL: process.env.N8N_BASE_URL },
},
);
} else {
for (const { name, config } of CONTAINER_CONFIGS) {
const grepInvertPatterns = [SERIAL_EXECUTION.source];
const grepInvertPatterns = [SERIAL_EXECUTION.source, ISOLATED_ONLY.source];
projects.push(
{
name: `${name}:ui`,
@ -61,7 +67,7 @@ export function getProjects(): Project[] {
{
name: `${name}:ui:isolated`,
testDir: './tests/ui',
grep: SERIAL_EXECUTION,
grep: new RegExp([SERIAL_EXECUTION.source, ISOLATED_ONLY.source].join('|')),
workers: 1,
use: { containerConfig: config },
},

View File

@ -0,0 +1,478 @@
import { test, expect } from '../../fixtures/base';
const OWNER_EMAIL = 'nathan@n8n.io';
const ADMIN_EMAIL = 'admin@n8n.io';
const MEMBER_0_EMAIL = 'member@n8n.io'; // U2
const MEMBER_1_EMAIL = 'member2@n8n.io'; // U3
const TEST_API_KEY = '1234567890';
const TEST_ACCESS_TOKEN = '1234567890';
test.describe('@isolated', () => {
test.describe.configure({ mode: 'serial' });
test.describe('Sharing - Workflow and Credential Sharing (Sequential)', () => {
test.beforeAll(async ({ api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
});
test('should create C1, W1, W2, share W1 with U3, as U2', async ({ n8n }) => {
await n8n.api.signin('member', 0);
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: TEST_API_KEY },
{ name: 'Credential C1' },
);
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName('Workflow W1');
await n8n.canvas.addNode('Manual Trigger');
await n8n.canvas.addNode('Notion', { action: 'Append a block' });
// Verify C1 auto-selected
await expect(n8n.ndv.getCredentialSelect()).toHaveValue('Credential C1');
await n8n.ndv.clickBackToCanvasButton();
// Share W1 with U3 before saving
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(MEMBER_1_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.canvas.saveWorkflow();
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Workflow W2');
});
test('should create C2, share C2 with U1 and U2, as U3', async ({ n8n }) => {
await n8n.api.signin('member', 1);
// Manual approach to access Sharing tab during creation
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Airtable Personal Access Token API');
await n8n.credentials.credentialModal.fillField('accessToken', TEST_ACCESS_TOKEN);
await n8n.credentials.credentialModal.getCredentialName().click();
await n8n.credentials.credentialModal.getNameInput().fill('Credential C2');
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.addUserToSharing(OWNER_EMAIL);
await n8n.credentials.credentialModal.addUserToSharing(MEMBER_0_EMAIL);
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
});
test('should open W1, add node using C2 as U3', async ({ n8n }) => {
await n8n.api.signin('member', 1);
await n8n.navigate.toWorkflows();
// U3 only sees W1 (not W2)
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(1);
await expect(n8n.workflows.cards.getWorkflow('Workflow W1')).toBeVisible();
await n8n.workflows.cards.getWorkflow('Workflow W1').click();
await n8n.canvas.addNode('Airtable', { action: 'Create a record' });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue('Credential C2');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.saveWorkflow();
await n8n.canvas.openNode('Append a block');
// C1 is shown but disabled (U3 doesn't own it)
await expect(n8n.ndv.getNodeCredentialsSelect()).toHaveValue('Credential C1');
await expect(n8n.ndv.getNodeCredentialsSelect()).toBeDisabled();
await n8n.ndv.clickBackToCanvasButton();
});
test('should open W1, add node using C2 as U2', async ({ n8n }) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toWorkflows();
// U2 sees W1 and W2 (both owned by U2)
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
await expect(n8n.workflows.cards.getWorkflow('Workflow W1')).toBeVisible();
await expect(n8n.workflows.cards.getWorkflow('Workflow W2')).toBeVisible();
await n8n.workflows.cards.getWorkflow('Workflow W1').click();
await n8n.canvas.addNode('Airtable', { action: 'Create a record' });
await expect(n8n.ndv.getCredentialSelect()).toHaveValue('Credential C2');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.saveWorkflow();
await n8n.canvas.openNode('Append a block');
// C1 is enabled (U2 owns it)
await expect(n8n.ndv.getNodeCredentialsSelect().locator('input')).toHaveValue(
'Credential C1',
);
await expect(n8n.ndv.getNodeCredentialsSelect().locator('input')).toBeEnabled();
await n8n.ndv.clickBackToCanvasButton();
});
test('should not have access to W2, as U3', async ({ n8n }) => {
const w2 = await n8n.workflowComposer.getWorkflowByName('Workflow W2');
await n8n.api.signin('member', 1);
await n8n.page.goto(`/workflow/${w2.id}`);
await expect(n8n.page).toHaveURL('/entity-not-authorized/workflow');
});
test('should have access to W1, W2, as U1', async ({ n8n }) => {
await n8n.api.signin('owner');
await n8n.navigate.toWorkflows();
// Owner sees W1 and W2 (created by U2)
await expect(n8n.workflows.cards.getWorkflows()).toHaveCount(2);
await expect(n8n.workflows.cards.getWorkflow('Workflow W1')).toBeVisible();
await expect(n8n.workflows.cards.getWorkflow('Workflow W2')).toBeVisible();
await n8n.workflows.cards.getWorkflow('Workflow W1').click();
// C1 is enabled for owner
await n8n.canvas.openNode('Append a block');
await expect(n8n.ndv.getNodeCredentialsSelect().locator('input')).toHaveValue(
'Credential C1',
);
await expect(n8n.ndv.getNodeCredentialsSelect().locator('input')).toBeEnabled();
await n8n.ndv.clickBackToCanvasButton();
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow('Workflow W2').click();
await n8n.canvas.clickExecuteWorkflowButton();
});
test('should automatically test C2 when opened by U2 sharee', async ({ n8n }) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentials.cards.getCredential('Credential C2').click();
await expect(n8n.credentials.credentialModal.getTestSuccessTag()).toBeVisible();
});
test('should work for admin role on credentials created by others', async ({ n8n }) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', TEST_API_KEY);
await n8n.credentials.credentialModal.renameCredential('Credential C3');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('admin');
await n8n.navigate.toCredentials();
await n8n.credentials.cards.getCredential('Credential C3').click();
await expect(n8n.credentials.credentialModal.getTestSuccessTag()).toBeVisible();
// Admin cannot see sensitive data (masked)
const passwordInput = n8n.credentials.credentialModal
.getCredentialInputs()
.locator('input')
.first();
const inputValue = await passwordInput.inputValue();
expect(inputValue).toContain('__n8n_BLANK_VALUE_');
await n8n.credentials.credentialModal.changeTab('Sharing');
await expect(
n8n.credentials.credentialModal
.getModal()
.getByText('Sharing a credential allows people to use it in their workflows'),
).toBeVisible();
await n8n.credentials.credentialModal.getUsersSelect().click();
await expect(
n8n.credentials.credentialModal.getVisibleDropdown().getByTestId('project-sharing-info'),
).toHaveCount(3);
// Admin can share with self
await expect(
n8n.credentials.credentialModal.getVisibleDropdown().getByText('admin@n8n.io'),
).toBeVisible();
await n8n.credentials.credentialModal.getVisibleDropdown().getByText(OWNER_EMAIL).click();
await n8n.credentials.credentialModal.addUserToSharing(MEMBER_1_EMAIL);
await n8n.credentials.credentialModal.addUserToSharing(ADMIN_EMAIL);
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
});
test('credentials should work between team and personal projects', async ({ n8n, api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.api.signin('owner');
await n8n.navigate.toHome();
await n8n.projectComposer.createProject('Development');
await n8n.sideBar.clickHomeButton();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test workflow');
await n8n.sideBar.clickHomeButton();
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentials.emptyListCreateCredentialButton.click();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', TEST_API_KEY);
await n8n.credentials.credentialModal.renameCredential('Notion API');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.credentials.cards.getCredential('Notion API').click();
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.getUsersSelect().click();
const sharingDropdown = n8n.credentials.credentialModal.getVisibleDropdown();
await expect(sharingDropdown.locator('li')).toHaveCount(4);
await expect(sharingDropdown.getByText('Development')).toBeVisible();
await sharingDropdown.getByText('Development').click();
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
await n8n.projectTabs.clickWorkflowsTab();
await n8n.workflows.shareWorkflow('Test workflow');
await n8n.workflowSharingModal.getUsersSelect().click();
const workflowSharingDropdown = n8n.workflowSharingModal.getVisibleDropdown();
await expect(workflowSharingDropdown.locator('li')).toHaveCount(3);
await workflowSharingDropdown.locator('li').first().click();
await n8n.workflowSharingModal.save();
await n8n.sideBar.getProjectMenuItems().first().click();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Test workflow 2');
// Team project workflow cannot be shared
await n8n.canvas.openShareModal();
await expect(n8n.workflowSharingModal.getUsersSelect()).toHaveCount(0);
await n8n.workflowSharingModal.close();
await n8n.sideBar.getProjectMenuItems().first().click();
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', TEST_API_KEY);
await n8n.credentials.credentialModal.renameCredential('Notion API 2');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.getUsersSelect().click();
const sharingDropdown2 = n8n.credentials.credentialModal.getVisibleDropdown();
await expect(sharingDropdown2.locator('li')).toHaveCount(4);
await sharingDropdown2.locator('li').first().click();
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
// One credential labeled "Personal"
await expect(n8n.credentials.cards.getCredentials()).toHaveCount(2);
await expect(
n8n.credentials.cards.getCredentials().filter({ hasText: 'Personal' }),
).toHaveCount(1);
});
});
test.describe('Credential Usage in Cross Shared Workflows', () => {
test.beforeEach(async ({ n8n, api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
});
test('should only show credentials from the same team project', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
const devProject = await n8n.projectComposer.createProject('Development');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: devProject.projectId },
);
const testProject = await n8n.projectComposer.createProject('Test');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: testProject.projectId },
);
await n8n.projectTabs.clickWorkflowsTab();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only Test project credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test('should only show credentials in their personal project for members', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.addUserToSharing(MEMBER_0_EMAIL);
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Own credential and shared credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(2);
});
test('should only show credentials in their personal project for members if the workflow was shared with them', async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(MEMBER_0_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only own credential visible (not owner's)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test("should show all credentials from all personal projects the workflow's been shared into for the global owner", async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.api.signin('member', 1);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('admin');
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(OWNER_EMAIL);
await n8n.workflowSharingModal.addUser(ADMIN_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees 3 credentials: admin's, U2's, owner's
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(3);
});
test('should show all personal credentials if the global owner owns the workflow', async ({
n8n,
}) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('owner');
await n8n.navigate.toWorkflows();
await n8n.workflows.addResource.workflow();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees member's credential (global owner privilege)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
});
});