From 8ca75d6f51732d0e70d26d568e87db4e8f3187c3 Mon Sep 17 00:00:00 2001 From: shortstacked Date: Thu, 21 Aug 2025 17:40:30 +0100 Subject: [PATCH] test: Add credential helper and ndv helper (#18636) --- .../playwright/helpers/NodeParameterHelper.ts | 106 ++++++++++ .../testing/playwright/pages/CanvasPage.ts | 12 ++ ...playViewPage.ts => NodeDetailsViewPage.ts} | 170 ++++++++++++++- packages/testing/playwright/pages/n8nPage.ts | 6 +- .../testing/playwright/services/api-helper.ts | 3 + .../services/credential-api-helper.ts | 195 ++++++++++++++++++ .../playwright/services/project-api-helper.ts | 15 ++ ...ec.ts => 01-workflow-entry-points.spec.ts} | 2 +- .../03-node-details-configuration.spec.ts | 50 +++++ .../ui/credential-api-operations.spec.ts | 195 ++++++++++++++++++ 10 files changed, 747 insertions(+), 7 deletions(-) create mode 100644 packages/testing/playwright/helpers/NodeParameterHelper.ts rename packages/testing/playwright/pages/{NodeDisplayViewPage.ts => NodeDetailsViewPage.ts} (55%) create mode 100644 packages/testing/playwright/services/credential-api-helper.ts rename packages/testing/playwright/tests/ui/building-blocks/{01-core-ui-patterns.spec.ts => 01-workflow-entry-points.spec.ts} (96%) create mode 100644 packages/testing/playwright/tests/ui/building-blocks/03-node-details-configuration.spec.ts create mode 100644 packages/testing/playwright/tests/ui/credential-api-operations.spec.ts diff --git a/packages/testing/playwright/helpers/NodeParameterHelper.ts b/packages/testing/playwright/helpers/NodeParameterHelper.ts new file mode 100644 index 0000000000..46f662ac3c --- /dev/null +++ b/packages/testing/playwright/helpers/NodeParameterHelper.ts @@ -0,0 +1,106 @@ +import type { NodeDetailsViewPage } from '../pages/NodeDetailsViewPage'; + +/** + * Helper class for setting node parameters in the NDV + */ +export class NodeParameterHelper { + constructor(private ndv: NodeDetailsViewPage) {} + + /** + * Detects parameter type by checking DOM structure + * Supports dropdown, text, and switch parameters + * @param parameterName - The parameter name to check + * @returns The detected parameter type + */ + async detectParameterType(parameterName: string): Promise<'dropdown' | 'text' | 'switch'> { + const parameterContainer = this.ndv.getParameterInput(parameterName); + const [hasSwitch, hasSelect, hasSelectCaret] = await Promise.all([ + parameterContainer + .locator('.el-switch') + .count() + .then((count) => count > 0), + parameterContainer + .locator('.el-select') + .count() + .then((count) => count > 0), + parameterContainer + .locator('.el-select__caret') + .count() + .then((count) => count > 0), + ]); + + if (hasSwitch) return 'switch'; + if (hasSelect && hasSelectCaret) return 'dropdown'; + return 'text'; + } + + /** + * Sets a parameter value with automatic type detection or explicit type + * Supports dropdown, text, and switch parameters + * @param parameterName - Name of the parameter to set + * @param value - Value to set (string or boolean) + * @param type - Optional explicit type to skip detection for better performance + */ + async setParameter( + parameterName: string, + value: string | boolean, + type?: 'dropdown' | 'text' | 'switch', + ): Promise { + if (typeof value === 'boolean') { + await this.ndv.setParameterSwitch(parameterName, value); + return; + } + + const parameterType = type ?? (await this.detectParameterType(parameterName)); + switch (parameterType) { + case 'dropdown': + await this.ndv.setParameterDropdown(parameterName, value); + break; + case 'text': + await this.ndv.setParameterInput(parameterName, value); + break; + case 'switch': + await this.ndv.setParameterSwitch(parameterName, value === 'true'); + break; + } + } + + async webhook(config: { + httpMethod?: string; + path?: string; + authentication?: string; + responseMode?: string; + }): Promise { + if (config.httpMethod !== undefined) + await this.setParameter('httpMethod', config.httpMethod, 'dropdown'); + if (config.path !== undefined) await this.setParameter('path', config.path, 'text'); + if (config.authentication !== undefined) + await this.setParameter('authentication', config.authentication, 'dropdown'); + if (config.responseMode !== undefined) + await this.setParameter('responseMode', config.responseMode, 'dropdown'); + } + + /** + * Simplified HTTP Request node parameter configuration + * @param config - Configuration object with parameter values + */ + async httpRequest(config: { + method?: string; + url?: string; + authentication?: string; + sendQuery?: boolean; + sendHeaders?: boolean; + sendBody?: boolean; + }): Promise { + if (config.method !== undefined) await this.setParameter('method', config.method, 'dropdown'); + if (config.url !== undefined) await this.setParameter('url', config.url, 'text'); + if (config.authentication !== undefined) + await this.setParameter('authentication', config.authentication, 'dropdown'); + if (config.sendQuery !== undefined) + await this.setParameter('sendQuery', config.sendQuery, 'switch'); + if (config.sendHeaders !== undefined) + await this.setParameter('sendHeaders', config.sendHeaders, 'switch'); + if (config.sendBody !== undefined) + await this.setParameter('sendBody', config.sendBody, 'switch'); + } +} diff --git a/packages/testing/playwright/pages/CanvasPage.ts b/packages/testing/playwright/pages/CanvasPage.ts index 3c6fb8b571..e5a7e7272e 100644 --- a/packages/testing/playwright/pages/CanvasPage.ts +++ b/packages/testing/playwright/pages/CanvasPage.ts @@ -77,6 +77,18 @@ export class CanvasPage extends BasePage { await this.nodeCreatorSubItem(subItemText).click(); } + async addActionNode(searchText: string, subItemText: string): Promise { + await this.addNode(searchText); + await this.page.getByText('Actions').click(); + await this.nodeCreatorSubItem(subItemText).click(); + } + + async addTriggerNode(searchText: string, subItemText: string): Promise { + await this.addNode(searchText); + await this.page.getByText('Triggers').click(); + await this.nodeCreatorSubItem(subItemText).click(); + } + async deleteNodeByName(nodeName: string): Promise { await this.nodeDeleteButton(nodeName).click(); } diff --git a/packages/testing/playwright/pages/NodeDisplayViewPage.ts b/packages/testing/playwright/pages/NodeDetailsViewPage.ts similarity index 55% rename from packages/testing/playwright/pages/NodeDisplayViewPage.ts rename to packages/testing/playwright/pages/NodeDetailsViewPage.ts index 5a423c9670..69585a1624 100644 --- a/packages/testing/playwright/pages/NodeDisplayViewPage.ts +++ b/packages/testing/playwright/pages/NodeDetailsViewPage.ts @@ -1,6 +1,17 @@ -import { BasePage } from './BasePage'; +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import { BasePage } from './BasePage'; +import { NodeParameterHelper } from '../helpers/NodeParameterHelper'; + +export class NodeDetailsViewPage extends BasePage { + readonly setupHelper: NodeParameterHelper; + + constructor(page: Page) { + super(page); + this.setupHelper = new NodeParameterHelper(this); + } -export class NodeDisplayViewPage extends BasePage { async clickBackToCanvasButton() { await this.clickByTestId('back-to-canvas'); } @@ -187,16 +198,82 @@ export class NodeDisplayViewPage extends BasePage { } /** - * Select option in parameter dropdown + * Get parameter input field + * @param parameterName - The name of the parameter + */ + getParameterInputField(parameterName: string) { + return this.getParameterInput(parameterName).getByTestId('parameter-input-field'); + } + + /** + * Select option in parameter dropdown (improved with Playwright best practices) * @param parameterName - The parameter name * @param optionText - The text of the option to select */ async selectOptionInParameterDropdown(parameterName: string, optionText: string) { const dropdown = this.getParameterInput(parameterName); await dropdown.click(); + + // Wait for dropdown to be visible and select option - following Playwright best practices await this.page.getByRole('option', { name: optionText }).click(); } + /** + * Click parameter dropdown by name (test-id based selector) + * @param parameterName - The parameter name e.g 'httpMethod', 'authentication' + */ + async clickParameterDropdown(parameterName: string): Promise { + await this.clickByTestId(`parameter-input-${parameterName}`); + } + + /** + * Select option from visible dropdown using Playwright role-based selectors + * This follows the pattern used in working n8n tests + * @param optionText - The text of the option to select + */ + async selectFromVisibleDropdown(optionText: string): Promise { + // Use Playwright's role-based selector - this is more reliable than CSS selectors + await this.page.getByRole('option', { name: optionText }).click(); + } + + /** + * Fill parameter input field by parameter name + * @param parameterName - The parameter name e.g 'path', 'url' + * @param value - The value to fill + */ + async fillParameterInputByName(parameterName: string, value: string): Promise { + const input = this.getParameterInputField(parameterName); + await input.click(); + await input.fill(value); + } + + /** + * Click parameter options expansion (e.g. for Response Code) + */ + async clickParameterOptions(): Promise { + await this.page.locator('.param-options').click(); + } + + /** + * Get visible Element UI popper (dropdown/popover) + * Ported from Cypress pattern with Playwright selectors + */ + getVisiblePopper() { + return this.page + .locator('.el-popper') + .filter({ hasNot: this.page.locator('[aria-hidden="true"]') }); + } + + /** + * Wait for parameter dropdown to be visible and ready for interaction + * @param parameterName - The parameter name + */ + async waitForParameterDropdown(parameterName: string): Promise { + const dropdown = this.getParameterInput(parameterName); + await dropdown.waitFor({ state: 'visible' }); + await expect(dropdown).toBeEnabled(); + } + /** * Click on a floating node in the NDV (for switching between connected nodes) * @param nodeName - The name of the node to click @@ -279,4 +356,91 @@ export class NodeDisplayViewPage extends BasePage { getErrorMessageText(message: string) { return this.page.locator(`text=${message}`); } + + async setParameterDropdown(parameterName: string, optionText: string): Promise { + await this.getParameterInput(parameterName).click(); + await this.page.getByRole('option', { name: optionText }).click(); + } + + async setParameterInput(parameterName: string, value: string): Promise { + await this.fillParameterInputByName(parameterName, value); + } + + async setParameterSwitch(parameterName: string, enabled: boolean): Promise { + const switchElement = this.getParameterInput(parameterName).locator('.el-switch'); + const isCurrentlyEnabled = (await switchElement.getAttribute('aria-checked')) === 'true'; + if (isCurrentlyEnabled !== enabled) { + await switchElement.click(); + } + } + + async setMultipleParameters( + parameters: Record, + ): Promise { + for (const [parameterName, value] of Object.entries(parameters)) { + if (typeof value === 'string') { + const parameterType = await this.setupHelper.detectParameterType(parameterName); + if (parameterType === 'dropdown') { + await this.setParameterDropdown(parameterName, value); + } else { + await this.setParameterInput(parameterName, value); + } + } else if (typeof value === 'boolean') { + await this.setParameterSwitch(parameterName, value); + } else if (typeof value === 'number') { + await this.setParameterInput(parameterName, value.toString()); + } + } + } + + async getParameterValue(parameterName: string): Promise { + const parameterType = await this.setupHelper.detectParameterType(parameterName); + + switch (parameterType) { + case 'text': + return await this.getTextParameterValue(parameterName); + case 'dropdown': + return await this.getDropdownParameterValue(parameterName); + case 'switch': + return await this.getSwitchParameterValue(parameterName); + default: + // Fallback for unknown types + return (await this.getParameterInput(parameterName).textContent()) ?? ''; + } + } + + /** + * Get value from a text parameter - simplified approach + */ + private async getTextParameterValue(parameterName: string): Promise { + const parameterContainer = this.getParameterInput(parameterName); + const input = parameterContainer.locator('input').first(); + return await input.inputValue(); + } + + /** + * Get value from a dropdown parameter + */ + private async getDropdownParameterValue(parameterName: string): Promise { + const selectedOption = this.getParameterInput(parameterName).locator('.el-select__tags-text'); + return (await selectedOption.textContent()) ?? ''; + } + + /** + * Get value from a switch parameter + */ + private async getSwitchParameterValue(parameterName: string): Promise { + const switchElement = this.getParameterInput(parameterName).locator('.el-switch'); + const isEnabled = (await switchElement.getAttribute('aria-checked')) === 'true'; + return isEnabled ? 'true' : 'false'; + } + + async validateParameter(parameterName: string, expectedValue: string): Promise { + const actualValue = await this.getParameterValue(parameterName); + if (actualValue !== expectedValue) { + throw new Error( + `Parameter ${parameterName} has value "${actualValue}", expected "${expectedValue}"`, + ); + } + } } diff --git a/packages/testing/playwright/pages/n8nPage.ts b/packages/testing/playwright/pages/n8nPage.ts index 8ceeffe1ce..560ac70788 100644 --- a/packages/testing/playwright/pages/n8nPage.ts +++ b/packages/testing/playwright/pages/n8nPage.ts @@ -6,7 +6,7 @@ import { CanvasPage } from './CanvasPage'; import { CredentialsPage } from './CredentialsPage'; import { ExecutionsPage } from './ExecutionsPage'; import { IframePage } from './IframePage'; -import { NodeDisplayViewPage } from './NodeDisplayViewPage'; +import { NodeDetailsViewPage } from './NodeDetailsViewPage'; import { NotificationsPage } from './NotificationsPage'; import { NpsSurveyPage } from './NpsSurveyPage'; import { ProjectSettingsPage } from './ProjectSettingsPage'; @@ -34,7 +34,7 @@ export class n8nPage { readonly canvas: CanvasPage; readonly iframe: IframePage; - readonly ndv: NodeDisplayViewPage; + readonly ndv: NodeDetailsViewPage; readonly npsSurvey: NpsSurveyPage; readonly projectSettings: ProjectSettingsPage; readonly settings: SettingsPage; @@ -66,7 +66,7 @@ export class n8nPage { this.canvas = new CanvasPage(page); this.iframe = new IframePage(page); - this.ndv = new NodeDisplayViewPage(page); + this.ndv = new NodeDetailsViewPage(page); this.npsSurvey = new NpsSurveyPage(page); this.projectSettings = new ProjectSettingsPage(page); this.settings = new SettingsPage(page); diff --git a/packages/testing/playwright/services/api-helper.ts b/packages/testing/playwright/services/api-helper.ts index 98e9d82c5b..38ab5c6ab0 100644 --- a/packages/testing/playwright/services/api-helper.ts +++ b/packages/testing/playwright/services/api-helper.ts @@ -9,6 +9,7 @@ import { INSTANCE_ADMIN_CREDENTIALS, } from '../config/test-users'; import { TestError } from '../Types'; +import { CredentialApiHelper } from './credential-api-helper'; import { ProjectApiHelper } from './project-api-helper'; import { WorkflowApiHelper } from './workflow-api-helper'; @@ -35,11 +36,13 @@ export class ApiHelpers { request: APIRequestContext; workflowApi: WorkflowApiHelper; projectApi: ProjectApiHelper; + credentialApi: CredentialApiHelper; constructor(requestContext: APIRequestContext) { this.request = requestContext; this.workflowApi = new WorkflowApiHelper(this); this.projectApi = new ProjectApiHelper(this); + this.credentialApi = new CredentialApiHelper(this); } // ===== MAIN SETUP METHODS ===== diff --git a/packages/testing/playwright/services/credential-api-helper.ts b/packages/testing/playwright/services/credential-api-helper.ts new file mode 100644 index 0000000000..ea7b2f277d --- /dev/null +++ b/packages/testing/playwright/services/credential-api-helper.ts @@ -0,0 +1,195 @@ +import type { + CreateCredentialDto, + CredentialsGetManyRequestQuery, + CredentialsGetOneRequestQuery, +} from '@n8n/api-types'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + +import type { ApiHelpers } from './api-helper'; +import { TestError } from '../Types'; + +interface CredentialResponse { + id: string; + name: string; + type: string; + data?: ICredentialDataDecryptedObject; + scopes?: string[]; + shared?: Array<{ + id: string; + projectId: string; + role: string; + }>; + createdAt: string; + updatedAt: string; +} + +type CredentialImportResult = { + credentialId: string; + createdCredential: CredentialResponse; +}; + +export class CredentialApiHelper { + constructor(private api: ApiHelpers) {} + + /** + * Create a new credential + */ + async createCredential(credential: CreateCredentialDto): Promise { + const response = await this.api.request.post('/rest/credentials', { data: credential }); + + if (!response.ok()) { + throw new TestError(`Failed to create credential: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Get all credentials with optional query parameters + */ + async getCredentials(query?: CredentialsGetManyRequestQuery): Promise { + const params = new URLSearchParams(); + if (query?.includeScopes) params.set('includeScopes', String(query.includeScopes)); + if (query?.includeData) params.set('includeData', String(query.includeData)); + if (query?.onlySharedWithMe) params.set('onlySharedWithMe', String(query.onlySharedWithMe)); + + const response = await this.api.request.get('/rest/credentials', { params }); + + if (!response.ok()) { + throw new TestError(`Failed to get credentials: ${await response.text()}`); + } + + const result = await response.json(); + return Array.isArray(result) ? result : (result.data ?? []); + } + + /** + * Get a specific credential by ID + */ + async getCredential( + credentialId: string, + query?: CredentialsGetOneRequestQuery, + ): Promise { + const params = new URLSearchParams(); + if (query?.includeData) params.set('includeData', String(query.includeData)); + + const response = await this.api.request.get(`/rest/credentials/${credentialId}`, { params }); + + if (!response.ok()) { + throw new TestError(`Failed to get credential: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Update an existing credential + */ + async updateCredential( + credentialId: string, + updates: Partial, + ): Promise { + const existingCredential = await this.getCredential(credentialId); + + const updateData = { + name: existingCredential.name, + type: existingCredential.type, + ...updates, + }; + + const response = await this.api.request.patch(`/rest/credentials/${credentialId}`, { + data: updateData, + }); + + if (!response.ok()) { + throw new TestError(`Failed to update credential: ${await response.text()}`); + } + + const result = await response.json(); + return result.data ?? result; + } + + /** + * Delete a credential + */ + async deleteCredential(credentialId: string): Promise { + const response = await this.api.request.delete(`/rest/credentials/${credentialId}`); + + if (!response.ok()) { + throw new TestError(`Failed to delete credential: ${await response.text()}`); + } + + return true; + } + + /** + * Get credentials available for a specific workflow or project + */ + async getCredentialsForWorkflow(options: { + workflowId?: string; + projectId?: string; + }): Promise { + const params = new URLSearchParams(); + if (options.workflowId) params.set('workflowId', options.workflowId); + if (options.projectId) params.set('projectId', options.projectId); + + const response = await this.api.request.get('/rest/credentials/for-workflow', { params }); + + if (!response.ok()) { + throw new TestError(`Failed to get credentials for workflow: ${await response.text()}`); + } + + const result = await response.json(); + return Array.isArray(result) ? result : (result.data ?? []); + } + + /** + * Transfer a credential to another project + */ + async transferCredential(credentialId: string, destinationProjectId: string): Promise { + const response = await this.api.request.put(`/rest/credentials/${credentialId}/transfer`, { + data: { destinationProjectId }, + }); + + if (!response.ok()) { + throw new TestError(`Failed to transfer credential: ${await response.text()}`); + } + } + + /** + * Make credential unique by adding a unique suffix to avoid naming conflicts in tests. + */ + private makeCredentialUnique( + credential: CreateCredentialDto, + options?: { idLength?: number }, + ): CreateCredentialDto { + const idLength = options?.idLength ?? 8; + const uniqueSuffix = nanoid(idLength); + + return { + ...credential, + name: `${credential.name} (Test ${uniqueSuffix})`, + }; + } + + /** + * Create a credential from definition with automatic unique naming for testing. + * Returns detailed information about what was created. + */ + async createCredentialFromDefinition( + credential: CreateCredentialDto, + options?: { idLength?: number }, + ): Promise { + const uniqueCredential = this.makeCredentialUnique(credential, options); + const createdCredential = await this.createCredential(uniqueCredential); + const credentialId = createdCredential.id; + + return { + credentialId, + createdCredential, + }; + } +} diff --git a/packages/testing/playwright/services/project-api-helper.ts b/packages/testing/playwright/services/project-api-helper.ts index ddfdb12291..90824ace1f 100644 --- a/packages/testing/playwright/services/project-api-helper.ts +++ b/packages/testing/playwright/services/project-api-helper.ts @@ -27,4 +27,19 @@ export class ProjectApiHelper { const result = await response.json(); return result.data ?? result; } + + /** + * Delete a project + * @param projectId The ID of the project to delete + * @returns True if deletion was successful + */ + async deleteProject(projectId: string): Promise { + const response = await this.api.request.delete(`/rest/projects/${projectId}`); + + if (!response.ok()) { + throw new TestError(`Failed to delete project: ${await response.text()}`); + } + + return true; + } } diff --git a/packages/testing/playwright/tests/ui/building-blocks/01-core-ui-patterns.spec.ts b/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts similarity index 96% rename from packages/testing/playwright/tests/ui/building-blocks/01-core-ui-patterns.spec.ts rename to packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts index 8624cd6727..87d69338f0 100644 --- a/packages/testing/playwright/tests/ui/building-blocks/01-core-ui-patterns.spec.ts +++ b/packages/testing/playwright/tests/ui/building-blocks/01-workflow-entry-points.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../../fixtures/base'; -test.describe('Core UI Patterns - Building Blocks', () => { +test.describe('01 - UI Test Entry Points', () => { test.describe('Entry Point: Home Page', () => { test('should navigate from home', async ({ n8n }) => { await n8n.start.fromHome(); diff --git a/packages/testing/playwright/tests/ui/building-blocks/03-node-details-configuration.spec.ts b/packages/testing/playwright/tests/ui/building-blocks/03-node-details-configuration.spec.ts new file mode 100644 index 0000000000..a6deda8bd0 --- /dev/null +++ b/packages/testing/playwright/tests/ui/building-blocks/03-node-details-configuration.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../../fixtures/base'; + +test.describe('03 - Node Details Configuration', () => { + test.beforeEach(async ({ n8n }) => { + await n8n.start.fromBlankCanvas(); + }); + + test('should configure webhook node', async ({ n8n }) => { + await n8n.canvas.addNode('Webhook'); + + await n8n.ndv.setupHelper.webhook({ + httpMethod: 'POST', + path: 'test-webhook', + authentication: 'Basic Auth', + }); + + await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('test-webhook'); + }); + + test('should configure HTTP Request node', async ({ n8n }) => { + await n8n.canvas.addNode('HTTP Request'); + + await n8n.ndv.setupHelper.httpRequest({ + method: 'POST', + url: 'https://api.example.com/test', + sendQuery: true, + sendHeaders: false, + }); + + await expect(n8n.ndv.getParameterInputField('url')).toHaveValue('https://api.example.com/test'); + }); + + test('should auto-detect parameter types', async ({ n8n }) => { + await n8n.canvas.addNode('Webhook'); + + await n8n.ndv.setupHelper.setParameter('httpMethod', 'PUT'); + await n8n.ndv.setupHelper.setParameter('path', 'auto-detect-test'); + + await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('auto-detect-test'); + }); + + test('should use explicit types for better performance', async ({ n8n }) => { + await n8n.canvas.addNode('Webhook'); + + await n8n.ndv.setupHelper.setParameter('httpMethod', 'PATCH', 'dropdown'); + await n8n.ndv.setupHelper.setParameter('path', 'explicit-types', 'text'); + + await expect(n8n.ndv.getParameterInputField('path')).toHaveValue('explicit-types'); + }); +}); diff --git a/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts b/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts new file mode 100644 index 0000000000..60fcc5c4d9 --- /dev/null +++ b/packages/testing/playwright/tests/ui/credential-api-operations.spec.ts @@ -0,0 +1,195 @@ +import type { CreateCredentialDto } from '@n8n/api-types'; + +import { test, expect } from '../../fixtures/base'; + +test.describe('Credential API Operations', () => { + test.describe('Basic CRUD Operations', () => { + test('should create, retrieve, update, and delete credential', async ({ api }) => { + const credentialData: CreateCredentialDto = { + name: 'Test HTTP Basic Auth', + type: 'httpBasicAuth', + data: { + user: 'test_user', + password: 'test_password', + }, + }; + + const { credentialId, createdCredential } = + await api.credentialApi.createCredentialFromDefinition(credentialData); + + expect(credentialId).toBeTruthy(); + expect(createdCredential.type).toBe('httpBasicAuth'); + expect(createdCredential.name).toContain('Test HTTP Basic Auth (Test'); + + const retrievedCredential = await api.credentialApi.getCredential(credentialId); + expect(retrievedCredential.id).toBe(credentialId); + expect(retrievedCredential.type).toBe('httpBasicAuth'); + expect(retrievedCredential.name).toBe(createdCredential.name); + + const credentialWithData = await api.credentialApi.getCredential(credentialId, { + includeData: true, + }); + expect(credentialWithData.data).toBeDefined(); + expect(credentialWithData.data?.user).toBe('test_user'); + + const updatedName = 'Updated HTTP Basic Auth'; + const updatedCredential = await api.credentialApi.updateCredential(credentialId, { + name: updatedName, + data: { + user: 'updated_user', + password: 'updated_password', + }, + }); + expect(updatedCredential.name).toBe(updatedName); + + const verifyUpdated = await api.credentialApi.getCredential(credentialId, { + includeData: true, + }); + expect(verifyUpdated.name).toBe(updatedName); + expect(verifyUpdated.data?.user).toBe('updated_user'); + + const deleteResult = await api.credentialApi.deleteCredential(credentialId); + expect(deleteResult).toBe(true); + + await expect(api.credentialApi.getCredential(credentialId)).rejects.toThrow(); + }); + }); + + test.describe('Credential Listing', () => { + test('should list credentials with different query options', async ({ api }) => { + const credential1 = await api.credentialApi.createCredentialFromDefinition({ + name: 'First Test Credential', + type: 'httpBasicAuth', + data: { user: 'user1', password: 'pass1' }, + }); + + const credential2 = await api.credentialApi.createCredentialFromDefinition({ + name: 'Second Test Credential', + type: 'httpHeaderAuth', + data: { name: 'Authorization', value: 'Bearer token' }, + }); + + const allCredentials = await api.credentialApi.getCredentials(); + expect(allCredentials.length).toBeGreaterThanOrEqual(2); + + const createdIds = [credential1.credentialId, credential2.credentialId]; + const foundCredentials = allCredentials.filter((c) => createdIds.includes(c.id)); + expect(foundCredentials).toHaveLength(2); + + const credentialsWithScopes = await api.credentialApi.getCredentials({ + includeScopes: true, + }); + expect(credentialsWithScopes[0].scopes).toBeDefined(); + expect(Array.isArray(credentialsWithScopes[0].scopes)).toBe(true); + + const credentialsWithData = await api.credentialApi.getCredentials({ + includeData: true, + }); + const foundWithData = credentialsWithData.filter((c) => createdIds.includes(c.id)); + expect(foundWithData.some((c) => c.data)).toBe(true); + }); + }); + + test.describe('Project Integration', () => { + test('should handle credential-project associations', async ({ api }) => { + await api.enableFeature('projectRole:admin'); + await api.enableFeature('projectRole:editor'); + await api.setMaxTeamProjectsQuota(-1); + + const project = await api.projectApi.createProject('Test Project for Credentials'); + + const credential = await api.credentialApi.createCredentialFromDefinition({ + name: 'Project Credential', + type: 'httpBasicAuth', + data: { user: 'user', password: 'pass' }, + projectId: project.id, + }); + + const projectCredentials = await api.credentialApi.getCredentialsForWorkflow({ + projectId: project.id, + }); + + expect(projectCredentials).toBeDefined(); + expect(Array.isArray(projectCredentials)).toBe(true); + + const foundCredential = projectCredentials.find((c) => c.id === credential.credentialId); + expect(foundCredential).toBeDefined(); + }); + + test('should transfer credential between projects', async ({ api }) => { + await api.enableFeature('projectRole:admin'); + await api.enableFeature('projectRole:editor'); + await api.setMaxTeamProjectsQuota(-1); + + const sourceProject = await api.projectApi.createProject('Source Project'); + const destinationProject = await api.projectApi.createProject('Destination Project'); + + const credential = await api.credentialApi.createCredentialFromDefinition({ + name: 'Transfer Test Credential', + type: 'httpBasicAuth', + data: { user: 'user', password: 'pass' }, + projectId: sourceProject.id, + }); + + const sourceCredentials = await api.credentialApi.getCredentialsForWorkflow({ + projectId: sourceProject.id, + }); + const foundInSource = sourceCredentials.find((c) => c.id === credential.credentialId); + expect(foundInSource).toBeDefined(); + + await api.credentialApi.transferCredential(credential.credentialId, destinationProject.id); + + const destinationCredentials = await api.credentialApi.getCredentialsForWorkflow({ + projectId: destinationProject.id, + }); + const foundInDestination = destinationCredentials.find( + (c) => c.id === credential.credentialId, + ); + expect(foundInDestination).toBeDefined(); + + const sourceCredentialsAfter = await api.credentialApi.getCredentialsForWorkflow({ + projectId: sourceProject.id, + }); + const stillInSource = sourceCredentialsAfter.find((c) => c.id === credential.credentialId); + expect(stillInSource).toBeUndefined(); + }); + }); + + test.describe('Data Persistence', () => { + test('should maintain credential data across operations', async ({ api }) => { + const originalData: CreateCredentialDto = { + name: 'Persistence Test Credential', + type: 'httpBasicAuth', + data: { + user: 'persistent_user', + password: 'persistent_password', + }, + }; + + const { credentialId } = await api.credentialApi.createCredentialFromDefinition(originalData); + + const afterCreate = await api.credentialApi.getCredential(credentialId, { + includeData: true, + }); + expect(afterCreate.data?.user).toBe('persistent_user'); + + await api.credentialApi.updateCredential(credentialId, { + data: { + user: 'updated_persistent_user', + password: 'updated_persistent_password', + }, + }); + + const afterUpdate = await api.credentialApi.getCredential(credentialId, { + includeData: true, + }); + expect(afterUpdate.data?.user).toBe('updated_persistent_user'); + expect(afterUpdate.data?.password).toBeDefined(); + + const allCredentials = await api.credentialApi.getCredentials(); + const foundCredential = allCredentials.find((c) => c.id === credentialId); + expect(foundCredential).toBeDefined(); + expect(foundCredential!.type).toBe('httpBasicAuth'); + }); + }); +});