// services/api-helper.ts import type { ClusterInfoResponse, InstanceAiPermissions } from '@n8n/api-types'; import { request, type APIRequestContext } from '@playwright/test'; import { setTimeout as wait } from 'node:timers/promises'; import type { UserCredentials } from '../config/test-users'; import { INSTANCE_OWNER_CREDENTIALS, INSTANCE_MEMBER_CREDENTIALS, INSTANCE_ADMIN_CREDENTIALS, INSTANCE_CHAT_CREDENTIALS, } from '../config/test-users'; import { TestError } from '../Types'; import { CredentialApiHelper } from './credential-api-helper'; import { DynamicCredentialApiHelper } from './dynamic-credential-api-helper'; import { ExternalSecretsApiHelper } from './external-secrets-api-helper'; import { McpApiHelper } from './mcp-api-helper'; import { ProjectApiHelper } from './project-api-helper'; import { PublicApiHelper } from './public-api-helper'; import { RoleApiHelper } from './role-api-helper'; import { SourceControlApiHelper } from './source-control-api-helper'; import { TagApiHelper } from './tag-api-helper'; import { UserApiHelper, type TestUser } from './user-api-helper'; import { VariablesApiHelper } from './variables-api-helper'; import { WebhookApiHelper } from './webhook-api-helper'; import { WorkflowApiHelper } from './workflow-api-helper'; export interface LoginResponseData { id: string; [key: string]: unknown; } export type UserRole = 'owner' | 'admin' | 'member' | 'chat'; export type TestState = 'fresh' | 'reset' | 'signin-only'; const AUTH_TAGS = { ADMIN: '@auth:admin', OWNER: '@auth:owner', MEMBER: '@auth:member', CHAT: '@auth:chat', NONE: '@auth:none', } as const; const DB_TAGS = { RESET: '@db:reset', } as const; export class ApiHelpers { request: APIRequestContext; workflows: WorkflowApiHelper; webhooks: WebhookApiHelper; mcp: McpApiHelper; projects: ProjectApiHelper; credentials: CredentialApiHelper; dynamicCredentials: DynamicCredentialApiHelper; variables: VariablesApiHelper; externalSecrets: ExternalSecretsApiHelper; users: UserApiHelper; tags: TagApiHelper; roles: RoleApiHelper; sourceControl: SourceControlApiHelper; publicApi: PublicApiHelper; constructor(requestContext: APIRequestContext) { this.request = requestContext; this.workflows = new WorkflowApiHelper(this); this.webhooks = new WebhookApiHelper(this); this.mcp = new McpApiHelper(this); this.projects = new ProjectApiHelper(this); this.credentials = new CredentialApiHelper(this); this.dynamicCredentials = new DynamicCredentialApiHelper(this); this.variables = new VariablesApiHelper(this); this.externalSecrets = new ExternalSecretsApiHelper(this); this.users = new UserApiHelper(this); this.tags = new TagApiHelper(this); this.roles = new RoleApiHelper(this); this.sourceControl = new SourceControlApiHelper(this); this.publicApi = new PublicApiHelper(this); } // ===== MAIN SETUP METHODS ===== /** * Setup test environment based on test tags * @param tags - Array of test tags (e.g., ['@db:reset', '@auth:owner']) * @param memberIndex - Which member to use (if auth role is 'member') * * Examples: * - ['@db:reset', '@auth:owner'] = reset DB + signin as owner * - ['@auth:admin'] = signin as admin (no reset) * - ['@auth:none'] = no signin (unauthenticated) */ async setupFromTags(tags: string[], memberIndex: number = 0): Promise { const shouldReset = this.shouldResetDatabase(tags); const role = this.getRoleFromTags(tags); if (shouldReset && role) { // Reset + signin await this.resetDatabase(); return await this.signin(role, memberIndex); } else if (shouldReset) { // Reset only, manual signin required await this.resetDatabase(); return null; } else if (role) { // Signin only return await this.signin(role, memberIndex); } // No setup required return null; } /** * Check if database should be reset based on tags */ private shouldResetDatabase(tags: string[]): boolean { const lowerTags = tags.map((tag) => tag.toLowerCase()); return lowerTags.includes(DB_TAGS.RESET.toLowerCase()); } /** * Setup test environment based on desired state (programmatic approach) * @param state - 'fresh': new container, 'reset': reset DB + signin, 'signin-only': just signin * @param role - User role to sign in as * @param memberIndex - Which member to use (if role is 'member') */ async setupTest( state: TestState, role: UserRole = 'owner', memberIndex: number = 0, ): Promise { switch (state) { case 'fresh': // For fresh docker container - just reset, no signin needed yet await this.resetDatabase(); return null; case 'reset': // Reset database then sign in await this.resetDatabase(); return await this.signin(role, memberIndex); case 'signin-only': // Just sign in without reset return await this.signin(role, memberIndex); default: throw new TestError('Unknown test state'); } } // ===== CORE METHODS ===== async resetDatabase(): Promise { const response = await this.request.post('/rest/e2e/reset', { data: { owner: INSTANCE_OWNER_CREDENTIALS, members: INSTANCE_MEMBER_CREDENTIALS, admin: INSTANCE_ADMIN_CREDENTIALS, chat: INSTANCE_CHAT_CREDENTIALS, }, }); if (!response.ok()) { const errorText = await response.text(); throw new TestError(errorText); } // Adding small delay to ensure database is reset await wait(1000); } async signin(role: UserRole, memberIndex: number = 0): Promise { const credentials = this.getCredentials(role, memberIndex); return await this.loginAndSetCookies(credentials); } async login(credentials: { email: string; password: string }): Promise { return await this.loginAndSetCookies(credentials); } // ===== CONFIGURATION METHODS ===== async setFeature(feature: string, enabled: boolean): Promise { await this.request.patch('/rest/e2e/feature', { data: { feature: `feat:${feature}`, enabled }, }); } async setQuota(quotaName: string, value: number | string): Promise { await this.request.patch('/rest/e2e/quota', { data: { feature: `quota:${quotaName}`, value }, }); } async setQueueMode(enabled: boolean): Promise { await this.request.patch('/rest/e2e/queue-mode', { data: { enabled }, }); } // ===== FEATURE FLAG METHODS ===== async setEnvFeatureFlags(flags: Record): Promise<{ data: { success: boolean; message: string; flags: Record; }; }> { const response = await this.request.patch('/rest/e2e/env-feature-flags', { data: { flags }, }); return await response.json(); } async clearEnvFeatureFlags(): Promise<{ data: { success: boolean; message: string; flags: Record; }; }> { const response = await this.request.patch('/rest/e2e/env-feature-flags', { data: { flags: {} }, }); return await response.json(); } async getEnvFeatureFlags(): Promise<{ data: Record; }> { const response = await this.request.get('/rest/e2e/env-feature-flags'); return await response.json(); } // ===== CONVENIENCE METHODS ===== async enableFeature(feature: string): Promise { await this.setFeature(feature, true); } /** * Enable all project features (sharing, folders, advancedPermissions, projectRoles) * Use this in API-only tests - the n8n fixture enables these via withProjectFeatures() */ async enableProjectFeatures(): Promise { await this.enableFeature('sharing'); await this.enableFeature('folders'); await this.enableFeature('advancedPermissions'); await this.enableFeature('projectRole:admin'); await this.enableFeature('projectRole:editor'); } async disableFeature(feature: string): Promise { await this.setFeature(feature, false); } async setMaxTeamProjectsQuota(value: number | string): Promise { await this.setQuota('maxTeamProjects', value); } /** * Create an isolated API context for a specific user. * Returns an ApiHelpers instance logged in as the specified user. * Use this for API-only operations without needing a browser context. */ async createApiForUser(user: Pick): Promise { const userContext = await request.newContext(); const userApi = new ApiHelpers(userContext); await userApi.login({ email: user.email, password: user.password }); return userApi; } /** * Fetch cluster info from the instance registry endpoint. */ async getClusterInfo(): Promise { const response = await this.request.get('/rest/instance-registry'); if (!response.ok()) { throw new TestError( `GET /rest/instance-registry failed (${response.status()}): ${await response.text()}`, ); } const plain = await response.json(); console.log('Cluster info: ', JSON.stringify(plain)); return (plain as { data: ClusterInfoResponse }).data; } async getInstanceAiToolTraceEvents(slug: string): Promise { const response = await this.request.get(`/rest/instance-ai/test/tool-trace/${slug}`); if (!response.ok()) { throw new TestError( `GET /rest/instance-ai/test/tool-trace/${slug} failed (${response.status()}): ${await response.text()}`, ); } const body = (await response.json()) as { data?: { events?: unknown[] } }; return body.data?.events ?? []; } async setInstanceAiPermissions(permissions: Partial): Promise { const response = await this.request.put('/rest/instance-ai/settings', { data: { permissions }, }); if (!response.ok()) { throw new TestError( `PUT /rest/instance-ai/settings failed (${response.status()}): ${await response.text()}`, ); } } /** * Check if n8n is healthy * @returns True if n8n is healthy, false otherwise */ async isHealthy(probe: 'liveness' | 'readiness' = 'liveness'): Promise { const url = probe === 'liveness' ? '/healthz' : '/healthz/readiness'; const response = await this.request.get(url); const data = await response.json(); return data.status === 'ok'; } // ===== LOG STREAMING METHODS ===== /** * Create a syslog destination for log streaming. * Requires the logStreaming feature to be enabled. * * @param config - Syslog destination configuration * @returns Created destination data */ async createSyslogDestination(config: { host: string; port: number; protocol?: 'tcp' | 'udp'; facility?: number; app_name?: string; label?: string; subscribedEvents?: string[]; }): Promise<{ id: string }> { const response = await this.request.post('/rest/eventbus/destination', { data: { __type: '$$MessageEventBusDestinationSyslog', host: config.host, port: config.port, protocol: config.protocol ?? 'tcp', facility: config.facility ?? 16, // Local0 app_name: config.app_name ?? 'n8n', label: config.label ?? 'VictoriaLogs Syslog', subscribedEvents: config.subscribedEvents ?? ['*'], // All events }, }); if (!response.ok()) { throw new TestError( `Failed to create syslog destination: ${response.status()} ${await response.text()}`, ); } const result = await response.json(); // Handle both direct response and {data: ...} wrapped response return result.data ?? result; } /** * Delete a log streaming destination. * * @param id - Destination ID to delete */ async deleteLogStreamingDestination(id: string): Promise { const response = await this.request.delete(`/rest/eventbus/destination?id=${id}`); if (!response.ok()) { throw new TestError( `Failed to delete log streaming destination: ${response.status()} ${await response.text()}`, ); } } /** * Get all log streaming destinations. * * @returns Array of destination configurations */ // eslint-disable-next-line @typescript-eslint/naming-convention async getLogStreamingDestinations(): Promise> { const response = await this.request.get('/rest/eventbus/destination'); if (!response.ok()) { throw new TestError( `Failed to get log streaming destinations: ${response.status()} ${await response.text()}`, ); } const result = await response.json(); // Handle both direct response and {data: ...} wrapped response return result.data ?? result; } /** * Send a test message to a log streaming destination. * * @param id - Destination ID to test * @returns True if test was successful */ async testLogStreamingDestination(id: string): Promise { const response = await this.request.get(`/rest/eventbus/testmessage?id=${id}`); if (!response.ok()) { return false; } const result = await response.json(); // Handle both direct response and {data: ...} wrapped response return result.data ?? result; } /** * Delete all log streaming destinations. */ async deleteAllLogStreamingDestinations(): Promise { const destinations = await this.getLogStreamingDestinations(); for (const destination of destinations) { await this.deleteLogStreamingDestination(destination.id); } } // ===== MCP API KEY METHODS ===== /** * Rotate the MCP API key for the authenticated user. * Creates a new API key and invalidates the old one. * * @returns The new MCP API key data */ async rotateMcpApiKey(): Promise<{ id: string; apiKey: string; userId: string }> { const response = await this.request.post('/rest/mcp/api-key/rotate'); if (!response.ok()) { throw new TestError( `Failed to rotate MCP API key: ${response.status()} ${await response.text()}`, ); } const result = await response.json(); return result.data ?? result; } /** * Enable or disable MCP access for the instance. * Uses the MCP settings endpoint to toggle access. * * @param enabled - Whether MCP access should be enabled */ async setMcpAccess(enabled: boolean): Promise { const response = await this.request.patch('/rest/mcp/settings', { data: { mcpAccessEnabled: enabled }, }); if (!response.ok()) { throw new TestError( `Failed to set MCP access: ${response.status()} ${await response.text()}`, ); } } // ===== PRIVATE METHODS ===== private async loginAndSetCookies( credentials: Pick, ): Promise { const response = await this.request.post('/rest/login', { data: { emailOrLdapLoginId: credentials.email, password: credentials.password, }, maxRetries: 3, }); if (!response.ok()) { const errorText = await response.text(); throw new TestError(errorText); } let responseData: unknown; try { responseData = await response.json(); } catch (error: unknown) { const errorText = await response.text(); throw new TestError(errorText); } const loginData: LoginResponseData = (responseData as { data: LoginResponseData }).data; if (!loginData?.id) { throw new TestError('Login did not return expected user data (missing user ID)'); } return loginData; } private getCredentials(role: UserRole, memberIndex: number = 0): UserCredentials { switch (role) { case 'owner': return INSTANCE_OWNER_CREDENTIALS; case 'admin': return INSTANCE_ADMIN_CREDENTIALS; case 'member': if (!INSTANCE_MEMBER_CREDENTIALS || memberIndex >= INSTANCE_MEMBER_CREDENTIALS.length) { throw new TestError(`No member credentials found for index ${memberIndex}`); } return INSTANCE_MEMBER_CREDENTIALS[memberIndex]; case 'chat': return INSTANCE_CHAT_CREDENTIALS; default: throw new TestError(`Unknown role: ${role as string}`); } } // ===== TAG PARSING METHODS ===== /** * Get the role from the tags * @param tags - Array of test tags (e.g., ['@auth:owner']) * @returns The role from the tags, or 'owner' if no role is found */ getRoleFromTags(tags: string[]): UserRole | null { const lowerTags = tags.map((tag) => tag.toLowerCase()); if (lowerTags.includes(AUTH_TAGS.ADMIN.toLowerCase())) return 'admin'; if (lowerTags.includes(AUTH_TAGS.OWNER.toLowerCase())) return 'owner'; if (lowerTags.includes(AUTH_TAGS.MEMBER.toLowerCase())) return 'member'; if (lowerTags.includes(AUTH_TAGS.CHAT.toLowerCase())) return 'chat'; if (lowerTags.includes(AUTH_TAGS.NONE.toLowerCase())) return null; return 'owner'; } }