n8n/packages/testing/playwright/services/api-helper.ts

536 lines
16 KiB
TypeScript

// 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<LoginResponseData | null> {
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<LoginResponseData | null> {
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<void> {
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<LoginResponseData> {
const credentials = this.getCredentials(role, memberIndex);
return await this.loginAndSetCookies(credentials);
}
async login(credentials: { email: string; password: string }): Promise<LoginResponseData> {
return await this.loginAndSetCookies(credentials);
}
// ===== CONFIGURATION METHODS =====
async setFeature(feature: string, enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/feature', {
data: { feature: `feat:${feature}`, enabled },
});
}
async setQuota(quotaName: string, value: number | string): Promise<void> {
await this.request.patch('/rest/e2e/quota', {
data: { feature: `quota:${quotaName}`, value },
});
}
async setQueueMode(enabled: boolean): Promise<void> {
await this.request.patch('/rest/e2e/queue-mode', {
data: { enabled },
});
}
// ===== FEATURE FLAG METHODS =====
async setEnvFeatureFlags(flags: Record<string, string>): Promise<{
data: {
success: boolean;
message: string;
flags: Record<string, string>;
};
}> {
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<string, string>;
};
}> {
const response = await this.request.patch('/rest/e2e/env-feature-flags', {
data: { flags: {} },
});
return await response.json();
}
async getEnvFeatureFlags(): Promise<{
data: Record<string, string>;
}> {
const response = await this.request.get('/rest/e2e/env-feature-flags');
return await response.json();
}
// ===== CONVENIENCE METHODS =====
async enableFeature(feature: string): Promise<void> {
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<void> {
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<void> {
await this.setFeature(feature, false);
}
async setMaxTeamProjectsQuota(value: number | string): Promise<void> {
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<TestUser, 'email' | 'password'>): Promise<ApiHelpers> {
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<ClusterInfoResponse> {
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<unknown[]> {
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<InstanceAiPermissions>): Promise<void> {
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<boolean> {
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<void> {
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<Array<{ id: string; __type: string }>> {
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<boolean> {
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<void> {
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<void> {
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<UserCredentials, 'email' | 'password'>,
): Promise<LoginResponseData> {
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';
}
}