From e3bb2f5c680605b0edd7469f1b73e598f6148565 Mon Sep 17 00:00:00 2001 From: Artem Sorokin <38620398+seemewalkin@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:16:55 +0100 Subject: [PATCH] test: Migrate sharing spec from Cypress to Playwright (#21024) --- .github/workflows/e2e-reusable.yml | 127 ----- .github/workflows/e2e-tests-pr.yml | 14 +- .github/workflows/e2e-tests.yml | 16 +- .../e2e/group1/48-subworkflow-inputs.cy.ts | 249 --------- cypress/e2e/group2/17-sharing.cy.ts | 428 ---------------- .../composables/WorkflowComposer.ts | 14 + .../playwright/fixtures/Test_workflow_1.json | 61 +++ .../testing/playwright/pages/CanvasPage.ts | 13 + .../playwright/pages/WorkflowSharingModal.ts | 9 + .../pages/components/CredentialModal.ts | 54 ++ .../pages/components/ProjectTabsComponent.ts | 23 + packages/testing/playwright/pages/n8nPage.ts | 9 + .../testing/playwright/playwright-projects.ts | 14 +- .../playwright/tests/ui/17-sharing.spec.ts | 478 ++++++++++++++++++ 14 files changed, 674 insertions(+), 835 deletions(-) delete mode 100644 .github/workflows/e2e-reusable.yml delete mode 100644 cypress/e2e/group1/48-subworkflow-inputs.cy.ts delete mode 100644 cypress/e2e/group2/17-sharing.cy.ts create mode 100644 packages/testing/playwright/fixtures/Test_workflow_1.json create mode 100644 packages/testing/playwright/pages/components/ProjectTabsComponent.ts create mode 100644 packages/testing/playwright/tests/ui/17-sharing.spec.ts diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml deleted file mode 100644 index ed20158646c..00000000000 --- a/.github/workflows/e2e-reusable.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml index 5f569b9d4b0..8d755afe2b2 100644 --- a/.github/workflows/e2e-tests-pr.yml +++ b/.github/workflows/e2e-tests-pr.yml @@ -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 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3b1ad762aef..674ba933c4a 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -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 diff --git a/cypress/e2e/group1/48-subworkflow-inputs.cy.ts b/cypress/e2e/group1/48-subworkflow-inputs.cy.ts deleted file mode 100644 index 028fa290e11..00000000000 --- a/cypress/e2e/group1/48-subworkflow-inputs.cy.ts +++ /dev/null @@ -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'; - } -} diff --git a/cypress/e2e/group2/17-sharing.cy.ts b/cypress/e2e/group2/17-sharing.cy.ts deleted file mode 100644 index 4d3195dfe5c..00000000000 --- a/cypress/e2e/group2/17-sharing.cy.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/testing/playwright/composables/WorkflowComposer.ts b/packages/testing/playwright/composables/WorkflowComposer.ts index 910cef200da..1ea77a18c8f 100644 --- a/packages/testing/playwright/composables/WorkflowComposer.ts +++ b/packages/testing/playwright/composables/WorkflowComposer.ts @@ -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 { + 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 diff --git a/packages/testing/playwright/fixtures/Test_workflow_1.json b/packages/testing/playwright/fixtures/Test_workflow_1.json new file mode 100644 index 00000000000..d10c44beca0 --- /dev/null +++ b/packages/testing/playwright/fixtures/Test_workflow_1.json @@ -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" + } + ] +} diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index d0ddae9aa00..f1b2c3d3e2f 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -307,6 +307,11 @@ export class CanvasPage extends BasePage { await responsePromise; } + async openShareModal(): Promise { + await this.clickByTestId('workflow-share-button'); + await this.page.getByTestId('workflowShare-modal').waitFor({ state: 'visible' }); + } + async clickZoomToFitButton(): Promise { 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"]') diff --git a/packages/testing/playwright/pages/WorkflowSharingModal.ts b/packages/testing/playwright/pages/WorkflowSharingModal.ts index f791ebb4e04..0a643ad8e0d 100644 --- a/packages/testing/playwright/pages/WorkflowSharingModal.ts +++ b/packages/testing/playwright/pages/WorkflowSharingModal.ts @@ -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() { diff --git a/packages/testing/playwright/pages/components/CredentialModal.ts b/packages/testing/playwright/pages/components/CredentialModal.ts index 70baf25567d..5671484d9af 100644 --- a/packages/testing/playwright/pages/components/CredentialModal.ts +++ b/packages/testing/playwright/pages/components/CredentialModal.ts @@ -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 { 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 { + 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 { + 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 { + 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, + }); + } } diff --git a/packages/testing/playwright/pages/components/ProjectTabsComponent.ts b/packages/testing/playwright/pages/components/ProjectTabsComponent.ts new file mode 100644 index 00000000000..09d019ec186 --- /dev/null +++ b/packages/testing/playwright/pages/components/ProjectTabsComponent.ts @@ -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(); + } +} diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index b6f4d7de619..0d39542b913 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -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); diff --git a/packages/testing/playwright/playwright-projects.ts b/packages/testing/playwright/playwright-projects.ts index 5594d2818b7..ecc9b4bfe3c 100644 --- a/packages/testing/playwright/playwright-projects.ts +++ b/packages/testing/playwright/playwright-projects.ts @@ -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 }, }, diff --git a/packages/testing/playwright/tests/ui/17-sharing.spec.ts b/packages/testing/playwright/tests/ui/17-sharing.spec.ts new file mode 100644 index 00000000000..725fdc46fe5 --- /dev/null +++ b/packages/testing/playwright/tests/ui/17-sharing.spec.ts @@ -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); + }); + }); +});