n8n/packages/testing/playwright/services/public-api-helper.ts
Ricardo Espinoza e04dddcbcc
feat(core): Remove license check for API key scopes (#27306)
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:10:43 +00:00

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;
}
}