mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 17:27:14 +02:00
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
import type { ApiKeyScope } from '@n8n/permissions';
|
|
import { request } from '@playwright/test';
|
|
import { nanoid } from 'nanoid';
|
|
|
|
import type { ApiHelpers } from './api-helper';
|
|
import type { TestUser } from './user-api-helper';
|
|
import { TestError } from '../Types';
|
|
|
|
export interface ApiKey {
|
|
id: string;
|
|
label: string;
|
|
apiKey: string;
|
|
rawApiKey: string;
|
|
createdAt: string;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
/** Default scopes for test API keys - covers most common operations.
|
|
* These scopes are owner-level; pass explicit scopes for member API keys. */
|
|
const DEFAULT_API_KEY_SCOPES: ApiKeyScope[] = [
|
|
'user:read',
|
|
'user:list',
|
|
'user:create',
|
|
'user:delete',
|
|
'workflow:create',
|
|
'workflow:read',
|
|
'workflow:update',
|
|
'workflow:delete',
|
|
'workflow:list',
|
|
'credential:create',
|
|
'credential:update',
|
|
'credential:delete',
|
|
'tag:create',
|
|
'tag:read',
|
|
'tag:list',
|
|
'project:create',
|
|
'project:update',
|
|
'project:delete',
|
|
'project:list',
|
|
];
|
|
|
|
/** Helper for working with n8n's Public API using API key authentication. */
|
|
export class PublicApiHelper {
|
|
private apiKey: string | null = null;
|
|
|
|
constructor(private readonly api: ApiHelpers) {}
|
|
|
|
async createApiKey(
|
|
label?: string,
|
|
scopes: ApiKeyScope[] = DEFAULT_API_KEY_SCOPES,
|
|
): Promise<ApiKey> {
|
|
const keyLabel = label ?? `E2E Test API Key ${nanoid()}`;
|
|
const response = await this.api.request.post('/rest/api-keys', {
|
|
data: { label: keyLabel, scopes, expiresAt: null },
|
|
});
|
|
|
|
if (!response.ok()) {
|
|
const errorText = await response.text();
|
|
throw new TestError(
|
|
`Failed to create API key "${keyLabel}": ${response.status()} ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const result = await response.json();
|
|
const apiKeyData = result.data ?? result;
|
|
this.apiKey = apiKeyData.rawApiKey;
|
|
return apiKeyData;
|
|
}
|
|
|
|
private async ensureApiKey(): Promise<string> {
|
|
if (!this.apiKey) {
|
|
await this.createApiKey();
|
|
}
|
|
return this.apiKey!;
|
|
}
|
|
|
|
private async getApiHeaders(): Promise<Record<string, string>> {
|
|
return { 'X-N8N-API-KEY': await this.ensureApiKey() };
|
|
}
|
|
|
|
/** Invite a user and return the invite accept URL for completing registration. */
|
|
async inviteUser(
|
|
email: string,
|
|
role: 'global:member' | 'global:admin' = 'global:member',
|
|
): Promise<{ id: string; email: string; inviteAcceptUrl: string; emailSent: boolean }> {
|
|
const headers = await this.getApiHeaders();
|
|
const response = await this.api.request.post('/api/v1/users', {
|
|
headers,
|
|
data: [{ email, role }],
|
|
});
|
|
|
|
if (!response.ok()) {
|
|
const errorText = await response.text();
|
|
throw new TestError(`Failed to invite user: ${response.status()} ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
const userResult = result[0];
|
|
|
|
if (!userResult) {
|
|
throw new TestError('Failed to invite user: empty response from API');
|
|
}
|
|
|
|
if (userResult.error) {
|
|
throw new TestError(`Failed to invite user: ${userResult.error}`);
|
|
}
|
|
|
|
return userResult.user;
|
|
}
|
|
|
|
/**
|
|
* Create a fully activated user by inviting them via the Public API and accepting the invitation.
|
|
*
|
|
* n8n's Public API doesn't have a direct "create user" endpoint. Users must be invited first,
|
|
* then accept the invitation to complete registration. The invitation acceptance endpoint
|
|
* (`/rest/invitations/accept`) expects a JWT token from the invite link and sets session cookies.
|
|
*
|
|
* To prevent this from hijacking the current browser session (which would log out the owner),
|
|
* we use an isolated request context for the acceptance step. This ensures the owner's session
|
|
* remains intact and multiple users can be created consecutively without session interference.
|
|
*/
|
|
async createUser(
|
|
options: {
|
|
email?: string;
|
|
password?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
role?: 'global:member' | 'global:admin';
|
|
} = {},
|
|
): Promise<TestUser> {
|
|
const email = options.email ?? `testuser-${nanoid()}@test.com`;
|
|
const password = options.password ?? 'PlaywrightTest123';
|
|
const firstName = options.firstName ?? 'Test';
|
|
const lastName = options.lastName ?? `User${nanoid()}`;
|
|
const role = options.role ?? 'global:member';
|
|
|
|
const invited = await this.inviteUser(email, role);
|
|
|
|
const url = new URL(invited.inviteAcceptUrl);
|
|
const token = url.searchParams.get('token');
|
|
if (!token) {
|
|
throw new TestError(`Invite URL has no token: ${invited.inviteAcceptUrl}`);
|
|
}
|
|
|
|
// Use an isolated request context to prevent session cookie contamination.
|
|
// The accept endpoint sets cookies that would otherwise override the current user's session.
|
|
const isolatedContext = await request.newContext({ baseURL: url.origin });
|
|
try {
|
|
const acceptResponse = await isolatedContext.post('/rest/invitations/accept', {
|
|
data: { token, firstName, lastName, password },
|
|
});
|
|
|
|
if (!acceptResponse.ok()) {
|
|
const errorText = await acceptResponse.text();
|
|
throw new TestError(`Failed to accept invitation: ${acceptResponse.status()} ${errorText}`);
|
|
}
|
|
} finally {
|
|
await isolatedContext.dispose();
|
|
}
|
|
|
|
return { id: invited.id, email, password, firstName, lastName, role: role as TestUser['role'] };
|
|
}
|
|
|
|
async getDiscovery(): Promise<{
|
|
scopes: string[];
|
|
resources: Record<
|
|
string,
|
|
{
|
|
operations: string[];
|
|
endpoints: Array<{ method: string; path: string; operationId: string }>;
|
|
}
|
|
>;
|
|
specUrl: string;
|
|
}> {
|
|
const headers = await this.getApiHeaders();
|
|
const response = await this.api.request.get('/api/v1/discover', { headers });
|
|
|
|
if (!response.ok()) {
|
|
const errorText = await response.text();
|
|
throw new TestError(`Failed to get discovery: ${response.status()} ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
return result.data;
|
|
}
|
|
|
|
async getUsers(options?: { includeRole?: boolean; limit?: number }): Promise<
|
|
Array<{ id: string; email: string; firstName: string; lastName: string; role?: string }>
|
|
> {
|
|
const headers = await this.getApiHeaders();
|
|
const params = new URLSearchParams();
|
|
|
|
if (options?.includeRole) params.set('includeRole', 'true');
|
|
if (options?.limit) params.set('limit', options.limit.toString());
|
|
|
|
const response = await this.api.request.get('/api/v1/users', { headers, params });
|
|
|
|
if (!response.ok()) {
|
|
const errorText = await response.text();
|
|
throw new TestError(`Failed to get users: ${response.status()} ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
return result.data;
|
|
}
|
|
}
|