Merge branch 'master' into pay-4195-replace-old-popover-component

This commit is contained in:
Csaba Tuncsik
2025-12-05 22:37:22 +02:00
committed by GitHub
43 changed files with 1159 additions and 1104 deletions

View File

@@ -43,7 +43,7 @@ jobs:
pnpm add --global wrangler
- name: Deploy
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65
uses: cloudflare/wrangler-action@707f63750981584eb6abc365a50d441516fb04b8
id: cloudflare_deployment
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -6,6 +6,7 @@ import type {
InvalidAuthTokenRepository,
UserRepository,
} from '@n8n/db';
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
import type { NextFunction, Response } from 'express';
import { mock } from 'jest-mock-extended';
import jwt from 'jsonwebtoken';
@@ -15,6 +16,7 @@ import { AUTH_COOKIE_NAME } from '@/constants';
import type { MfaService } from '@/mfa/mfa.service';
import { JwtService } from '@/services/jwt.service';
import type { UrlService } from '@/services/url.service';
import type { License } from '@/license';
describe('AuthService', () => {
const browserId = 'test-browser-id';
@@ -35,10 +37,11 @@ describe('AuthService', () => {
const userRepository = mock<UserRepository>();
const invalidAuthTokenRepository = mock<InvalidAuthTokenRepository>();
const mfaService = mock<MfaService>();
const license = mock<License>();
const authService = new AuthService(
globalConfig,
mock(),
mock(),
license,
jwtService,
urlService,
userRepository,
@@ -61,6 +64,7 @@ describe('AuthService', () => {
globalConfig.userManagement.jwtSessionDurationHours = 168;
globalConfig.userManagement.jwtRefreshTimeoutHours = 0;
globalConfig.auth.cookie = { secure: true, samesite: 'lax' };
license.isWithinUsersLimit.mockReturnValue(true);
});
describe('createJWTHash', () => {
@@ -520,6 +524,29 @@ describe('AuthService', () => {
});
});
describe('when user limit is reached', () => {
it('should block issuance if the user is not the global owner', async () => {
license.isWithinUsersLimit.mockReturnValue(false);
expect(() => {
authService.issueCookie(res, user, false, browserId);
}).toThrowError('Maximum number of users reached');
});
it('should allow issuance if the user is the global owner', async () => {
license.isWithinUsersLimit.mockReturnValue(false);
user.role = GLOBAL_OWNER_ROLE;
expect(() => {
authService.issueCookie(res, user, false, browserId);
}).not.toThrowError('Maximum number of users reached');
expect(res.cookie).toHaveBeenCalledWith('n8n-auth', validToken, {
httpOnly: true,
maxAge: 604800000,
sameSite: 'lax',
secure: true,
});
});
});
it('should issue a cookie with the correct options, when 2FA was used', () => {
authService.issueCookie(res, user, true, browserId);

View File

@@ -10,7 +10,6 @@ import type { NextFunction, Response } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { StringValue as TimeUnitValue } from 'ms';
import config from '@/config';
import { AuthError } from '@/errors/response-errors/auth.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { License } from '@/license';
@@ -171,11 +170,7 @@ export class AuthService {
// TODO: move this check to the login endpoint in AuthController
// If the instance has exceeded its user quota, prevent non-owners from logging in
const isWithinUsersLimit = this.license.isWithinUsersLimit();
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
user.role.slug !== GLOBAL_OWNER_ROLE.slug &&
!isWithinUsersLimit
) {
if (user.role.slug !== GLOBAL_OWNER_ROLE.slug && !isWithinUsersLimit) {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}

View File

@@ -3,7 +3,6 @@ import {
User,
CredentialsRepository,
ProjectRepository,
SettingsRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
UserRepository,
@@ -19,6 +18,7 @@ const defaultUserProps = {
lastName: null,
email: null,
password: null,
lastActiveAt: null,
role: 'global:owner',
};
@@ -53,11 +53,6 @@ export class Reset extends BaseCommand {
);
await Container.get(SharedCredentialsRepository).save(newSharedCredentials);
await Container.get(SettingsRepository).update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: 'false' },
);
this.logger.info('Successfully reset the database to default user state.');
}

View File

@@ -7,10 +7,12 @@ import { Container } from '@n8n/di';
export const schema = {
userManagement: {
/**
* @important Do not remove until after cloud hooks are updated to stop using convict config.
* @important Do not remove isInstanceOwnerSetUp until after cloud hooks (user-management) are updated to stop using
* this property
* @deprecated
*/
isInstanceOwnerSetUp: {
// n8n loads this setting from DB on startup
// n8n loads this setting from SettingsRepository (DB) on startup
doc: "Whether the instance owner's account has been set up",
format: Boolean,
default: false,

View File

@@ -76,7 +76,6 @@ type ToReturnType<T extends ConfigOptionPath> = T extends NumericPath
type ExceptionPaths = {
'queue.bull.redis': RedisOptions;
processedDataManager: IProcessedDataConfig;
'userManagement.isInstanceOwnerSetUp': boolean;
'ui.banners.dismissed': string[] | undefined;
easyAIWorkflowOnboarded: boolean | undefined;
};

View File

@@ -22,6 +22,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import config from '@/config';
import type { AuthlessRequest } from '@/requests';
import { v4 as uuidv4 } from 'uuid';
import { OwnershipService } from '@/services/ownership.service';
describe('InvitationController', () => {
const logger: Logger = mockInstance(Logger);
@@ -33,22 +34,29 @@ describe('InvitationController', () => {
const userRepository: UserRepository = mockInstance(UserRepository);
const postHog: PostHogClient = mockInstance(PostHogClient);
const eventService: EventService = mockInstance(EventService);
const ownershipService: OwnershipService = mockInstance(OwnershipService);
function defaultInvitationController() {
return new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
ownershipService,
);
}
describe('inviteUser', () => {
it('throws a BadRequestError if SSO is enabled', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const user = mock<User>({
id: '123',
@@ -77,18 +85,9 @@ describe('InvitationController', () => {
it('throws a ForbiddenError if the user limit quota has been reached', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const user = mock<User>({
id: '123',
@@ -112,18 +111,9 @@ describe('InvitationController', () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
jest.spyOn(config, 'getEnv').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(false));
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const user = mock<User>({
id: '123',
@@ -148,18 +138,9 @@ describe('InvitationController', () => {
jest.spyOn(license, 'isWithinUsersLimit').mockReturnValue(true);
jest.spyOn(config, 'getEnv').mockReturnValue(true);
jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const user = mock<User>({
id: '123',
@@ -209,17 +190,9 @@ describe('InvitationController', () => {
jest.spyOn(config, 'getEnv').mockReturnValue(true);
jest.spyOn(license, 'isAdvancedPermissionsLicensed').mockReturnValue(true);
jest.spyOn(userService, 'inviteUsers').mockResolvedValue(inviteUsersResult);
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const invitationController = defaultInvitationController();
const user = mock<User>({
id: '123',
@@ -255,19 +228,11 @@ describe('InvitationController', () => {
describe('acceptInvitation', () => {
it('throws a BadRequestError if SSO is enabled', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(true);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const id = uuidv4();
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const payload = new AcceptInvitationRequestDto({
inviterId: id,
@@ -291,19 +256,11 @@ describe('InvitationController', () => {
it('throws a BadRequestError if the inviter ID and invitee ID are not found in the database', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const id = uuidv4();
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const payload = new AcceptInvitationRequestDto({
inviterId: id,
@@ -332,6 +289,8 @@ describe('InvitationController', () => {
it('throws a BadRequestError if the invitee already has a password', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const invitee = mock<User>({
id: '123',
email: 'valid@email.com',
@@ -346,17 +305,7 @@ describe('InvitationController', () => {
jest.spyOn(userRepository, 'find').mockResolvedValue([inviter, invitee]);
const id = uuidv4();
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const payload = new AcceptInvitationRequestDto({
inviterId: id,
@@ -379,6 +328,8 @@ describe('InvitationController', () => {
it('accepts the invitation successfully', async () => {
jest.spyOn(ssoHelpers, 'isSsoCurrentAuthenticationMethod').mockReturnValue(false);
jest.spyOn(ownershipService, 'hasInstanceOwner').mockReturnValue(Promise.resolve(true));
const id = uuidv4();
const inviter = mock<User>({
id: '124',
@@ -400,17 +351,7 @@ describe('InvitationController', () => {
jest.spyOn(userService, 'toPublic').mockResolvedValue(invitee as unknown as PublicUser);
jest.spyOn(externalHooks, 'run').mockResolvedValue(invitee as never);
const invitationController = new InvitationController(
logger,
externalHooks,
authService,
userService,
license,
passwordUtility,
userRepository,
postHog,
eventService,
);
const invitationController = defaultInvitationController();
const payload = new AcceptInvitationRequestDto({
inviterId: id,

View File

@@ -1,103 +1,40 @@
import type { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import {
type AuthenticatedRequest,
type User,
type PublicUser,
type SettingsRepository,
type UserRepository,
GLOBAL_OWNER_ROLE,
} from '@n8n/db';
import type { Response } from 'express';
import type { DismissBannerRequestDto } from '@n8n/api-types';
import { mock } from 'jest-mock-extended';
import type { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { OwnerController } from '@/controllers/owner.controller';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { EventService } from '@/events/event.service';
import type { BannerService } from '@/services/banner.service';
import type { PasswordUtility } from '@/services/password.utility';
import type { UserService } from '@/services/user.service';
import type { OwnershipService } from '@/services/ownership.service';
import type { PostHogClient } from '@/posthog';
describe('OwnerController', () => {
const configGetSpy = jest.spyOn(config, 'getEnv');
const configSetSpy = jest.spyOn(config, 'set');
const logger = mock<Logger>();
const eventService = mock<EventService>();
const authService = mock<AuthService>();
const bannerService = mock<BannerService>();
const userService = mock<UserService>();
const userRepository = mock<UserRepository>();
const settingsRepository = mock<SettingsRepository>();
const passwordUtility = mock<PasswordUtility>();
const ownershipService = mock<OwnershipService>();
const postHogClient = mock<PostHogClient>();
const controller = new OwnerController(
logger,
eventService,
settingsRepository,
authService,
bannerService,
userService,
passwordUtility,
mock(),
userRepository,
postHogClient,
ownershipService,
);
describe('setupOwner', () => {
it('should throw a BadRequestError if the instance owner is already setup', async () => {
configGetSpy.mockReturnValue(true);
it('should pass on errors from the service', async () => {
jest
.spyOn(ownershipService, 'setupOwner')
.mockRejectedValueOnce(new BadRequestError('Instance owner already setup'));
await expect(controller.setupOwner(mock(), mock(), mock())).rejects.toThrowError(
new BadRequestError('Instance owner already setup'),
);
expect(userRepository.findOneOrFail).not.toHaveBeenCalled();
expect(userRepository.save).not.toHaveBeenCalled();
expect(authService.issueCookie).not.toHaveBeenCalled();
expect(settingsRepository.update).not.toHaveBeenCalled();
expect(configSetSpy).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'Request to claim instance ownership failed because instance owner already exists',
);
});
it('should setup the instance owner successfully', async () => {
const user = mock<User>({
id: 'userId',
role: GLOBAL_OWNER_ROLE,
authIdentities: [],
});
const browserId = 'test-browser-id';
const req = mock<AuthenticatedRequest>({ user, browserId, authInfo: { usedMfa: false } });
const res = mock<Response>();
const payload = mock<OwnerSetupRequestDto>({
email: 'valid@email.com',
password: 'NewPassword123',
firstName: 'Jane',
lastName: 'Doe',
});
configGetSpy.mockReturnValue(false);
userRepository.findOneOrFail.mockResolvedValue(user);
userRepository.save.mockResolvedValue(user);
userService.toPublic.mockResolvedValue(mock<PublicUser>({ id: 'newUserId' }));
const result = await controller.setupOwner(req, res, payload);
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
relations: ['role'],
});
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, false, browserId);
expect(settingsRepository.update).toHaveBeenCalledWith(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
expect(configSetSpy).toHaveBeenCalledWith('userManagement.isInstanceOwnerSetUp', true);
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', { userId: 'userId' });
expect(result.id).toEqual('newUserId');
});
});

View File

@@ -16,7 +16,6 @@ import { Request } from 'express';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import { inE2ETests } from '@/constants';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import type { FeatureReturnType } from '@/license';
@@ -223,8 +222,7 @@ export class E2EController {
@Get('/env-feature-flags', { skipAuth: true })
async getEnvFeatureFlags() {
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
return currentFlags;
return (await this.frontendService.getSettings()).envFeatureFlags;
}
@Patch('/env-feature-flags', { skipAuth: true })
@@ -254,7 +252,7 @@ export class E2EController {
}
// Return the current environment feature flags
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
const currentFlags = (await this.frontendService.getSettings()).envFeatureFlags;
return {
success: true,
message: 'Environment feature flags updated',
@@ -364,13 +362,6 @@ export class E2EController {
mfaRecoveryCodes: encryptedRecoveryCodes,
});
}
await this.settingsRepo.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: 'true' },
);
config.set('userManagement.isInstanceOwnerSetUp', true);
}
private async resetCache() {

View File

@@ -6,7 +6,6 @@ import { Post, GlobalScope, RestController, Body, Param } from '@n8n/decorators'
import { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
@@ -17,6 +16,7 @@ import { PostHogClient } from '@/posthog';
import { AuthlessRequest } from '@/requests';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
import { OwnershipService } from '@/services/ownership.service';
import { isSsoCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
@RestController('/invitations')
@@ -31,6 +31,7 @@ export class InvitationController {
private readonly userRepository: UserRepository,
private readonly postHog: PostHogClient,
private readonly eventService: EventService,
private readonly ownershipService: OwnershipService,
) {}
/**
@@ -64,7 +65,7 @@ export class InvitationController {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
if (!(await this.ownershipService.hasInstanceOwner())) {
this.logger.debug(
'Request to send email invite(s) to user(s) failed because the owner account is not set up',
);

View File

@@ -1,82 +1,31 @@
import { DismissBannerRequestDto, OwnerSetupRequestDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import {
AuthenticatedRequest,
GLOBAL_OWNER_ROLE,
SettingsRepository,
UserRepository,
} from '@n8n/db';
import { AuthenticatedRequest } from '@n8n/db';
import { Body, GlobalScope, Post, RestController } from '@n8n/decorators';
import { Response } from 'express';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { validateEntity } from '@/generic-helpers';
import { PostHogClient } from '@/posthog';
import { BannerService } from '@/services/banner.service';
import { PasswordUtility } from '@/services/password.utility';
import { UserService } from '@/services/user.service';
import { OwnershipService } from '@/services/ownership.service';
@RestController('/owner')
export class OwnerController {
constructor(
private readonly logger: Logger,
private readonly eventService: EventService,
private readonly settingsRepository: SettingsRepository,
private readonly authService: AuthService,
private readonly bannerService: BannerService,
private readonly userService: UserService,
private readonly passwordUtility: PasswordUtility,
private readonly postHog: PostHogClient,
private readonly userRepository: UserRepository,
private readonly ownershipService: OwnershipService,
) {}
/**
* Promote a shell into the owner of the n8n instance,
* and enable `isInstanceOwnerSetUp` setting.
* Promote a shell into the owner of the n8n instance
*/
@Post('/setup', { skipAuth: true })
async setupOwner(req: AuthenticatedRequest, res: Response, @Body payload: OwnerSetupRequestDto) {
const { email, firstName, lastName, password } = payload;
if (config.getEnv('userManagement.isInstanceOwnerSetUp')) {
this.logger.debug(
'Request to claim instance ownership failed because instance owner already exists',
);
throw new BadRequestError('Instance owner already setup');
}
let owner = await this.userRepository.findOneOrFail({
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
relations: ['role'],
});
owner.email = email;
owner.firstName = firstName;
owner.lastName = lastName;
owner.password = await this.passwordUtility.hash(password);
// TODO: move XSS validation out into the DTO class
await validateEntity(owner);
owner = await this.userRepository.save(owner, { transaction: false });
this.logger.info('Owner was set up successfully');
await this.settingsRepository.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
config.set('userManagement.isInstanceOwnerSetUp', true);
this.logger.debug('Setting isInstanceOwnerSetUp updated successfully');
const owner = await this.ownershipService.setupOwner(payload);
this.authService.issueCookie(res, owner, req.authInfo?.usedMfa ?? false, req.browserId);
this.eventService.emit('instance-owner-setup', { userId: owner.id });
return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true });
}

View File

@@ -1,4 +1,4 @@
import { In, type WorkflowRepository, type User } from '@n8n/db';
import { In, WorkflowRepository, type User } from '@n8n/db';
import { getBase } from '@/workflow-execute-additional-data';
import { ChatHubAgentService } from './chat-hub-agent.service';

View File

@@ -200,7 +200,7 @@ export class Server extends AbstractServer {
const { frontendService } = this;
if (frontendService) {
await this.externalHooks.run('frontend.settings', [frontendService.getSettings()]);
await this.externalHooks.run('frontend.settings', [await frontendService.getSettings()]);
}
await this.postHogClient.init();
@@ -215,7 +215,7 @@ export class Server extends AbstractServer {
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint);
this.app.use(...apiRouters);
if (frontendService) {
frontendService.settings.publicApi.latestVersion = apiLatestVersion;
(await frontendService.getSettings()).publicApi.latestVersion = apiLatestVersion;
}
}
@@ -487,7 +487,9 @@ export class Server extends AbstractServer {
`/${this.restEndpoint}/settings`,
authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }),
ResponseHelper.send(async (req: AuthenticatedRequest) => {
return req.user ? frontendService.getSettings() : frontendService.getPublicSettings();
return req.user
? await frontendService.getSettings()
: await frontendService.getPublicSettings();
}),
);
}

View File

@@ -14,6 +14,7 @@ import type { PushConfig } from '@/push/push.config';
import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service';
import type { UrlService } from '@/services/url.service';
import type { UserManagementMailer } from '@/user-management/email';
import type { OwnershipService } from '../ownership.service';
// Mock the workflow history helper functions to avoid DI container issues in tests
jest.mock('@/workflows/workflow-history/workflow-history-helper', () => ({
@@ -148,6 +149,10 @@ describe('FrontendService', () => {
isMFAEnforced: jest.fn().mockReturnValue(false),
});
const ownershipService = mock<OwnershipService>({
hasInstanceOwner: jest.fn().mockReturnValue(false),
});
const createMockService = () => {
Container.set(
CommunityPackagesConfig,
@@ -173,6 +178,7 @@ describe('FrontendService', () => {
licenseState,
moduleRegistry,
mfaService,
ownershipService,
),
license,
};
@@ -188,9 +194,9 @@ describe('FrontendService', () => {
});
describe('getSettings', () => {
it('should return frontend settings', () => {
it('should return frontend settings', async () => {
const { service } = createMockService();
const settings = service.getSettings();
const settings = await service.getSettings();
expect(settings).toEqual(
expect.objectContaining({
@@ -201,7 +207,7 @@ describe('FrontendService', () => {
});
describe('getPublicSettings', () => {
it('should return public settings', () => {
it('should return public settings', async () => {
const expectedPublicSettings: PublicFrontendSettings = {
settingsMode: 'public',
userManagement: {
@@ -223,7 +229,7 @@ describe('FrontendService', () => {
};
const { service } = createMockService();
const settings = service.getPublicSettings();
const settings = await service.getPublicSettings();
expect(settings).toEqual(expectedPublicSettings);
});
@@ -282,29 +288,31 @@ describe('FrontendService', () => {
});
describe('settings integration', () => {
it('should include envFeatureFlags in initial settings', () => {
it('should include envFeatureFlags in initial settings', async () => {
process.env = {
N8N_ENV_FEAT_INIT_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
};
const { service } = createMockService();
const settings = await service.getSettings();
expect(service.settings.envFeatureFlags).toEqual({
expect(settings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INIT_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
});
});
it('should refresh envFeatureFlags when getSettings is called', () => {
it('should refresh envFeatureFlags when getSettings is called', async () => {
process.env = {
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
};
const { service } = createMockService();
const initialSettings = await service.getSettings();
// Verify initial state
expect(service.settings.envFeatureFlags).toEqual({
expect(initialSettings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
});
@@ -315,7 +323,7 @@ describe('FrontendService', () => {
};
// getSettings should refresh the flags
const settings = service.getSettings();
const settings = await service.getSettings();
expect(settings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
@@ -326,33 +334,33 @@ describe('FrontendService', () => {
});
describe('aiBuilder setting', () => {
it('should initialize aiBuilder setting as disabled by default', () => {
it('should initialize aiBuilder setting as disabled by default', async () => {
const { service } = createMockService();
expect(service.settings.aiBuilder).toEqual({
const initialSettings = await service.getSettings();
expect(initialSettings.aiBuilder).toEqual({
enabled: false,
setup: false,
});
});
it('should set aiBuilder.enabled to true when license has feat:aiBuilder', () => {
it('should set aiBuilder.enabled to true when license has feat:aiBuilder', async () => {
const { service, license } = createMockService();
license.isLicensed.mockImplementation((feature) => {
return feature === 'feat:aiBuilder';
});
const settings = service.getSettings();
const settings = await service.getSettings();
expect(settings.aiBuilder.enabled).toBe(true);
});
it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', () => {
it('should keep aiBuilder.enabled as false when license does not have feat:aiBuilder', async () => {
const { service, license } = createMockService();
license.isLicensed.mockReturnValue(false);
const settings = service.getSettings();
const settings = await service.getSettings();
expect(settings.aiBuilder.enabled).toBe(false);
});

View File

@@ -0,0 +1,60 @@
import { testDb } from '@n8n/backend-test-utils';
import { GLOBAL_OWNER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import { HooksService } from '@/services/hooks.service';
import { OwnershipService } from '@/services/ownership.service';
import { createUserShell } from '@test-integration/db/users';
let hookService: HooksService;
let ownershipService: OwnershipService;
// See PAY-4247 - This test case can be deleted when the ticket is complete
describe('Ownership Service integration test', () => {
beforeEach(async () => {
await testDb.truncate(['User']);
await createUserShell(GLOBAL_OWNER_ROLE);
jest.clearAllMocks();
});
beforeAll(async () => {
await testDb.init();
hookService = Container.get(HooksService);
ownershipService = Container.get(OwnershipService);
});
afterAll(async () => {
await testDb.terminate();
});
it('should recognise ownership creation from cloud hooks', async () => {
expect(await ownershipService.hasInstanceOwner()).toBeFalsy();
const shellOwnerUser = await hookService.findOneUser({
where: {
role: {
slug: GLOBAL_OWNER_ROLE.slug,
},
},
});
// @ts-expect-error - this is how this function is called in the cloud hook so I match it here
await hookService.saveUser({
firstName: 'FN',
lastName: 'LN',
email: 'fn@ln.com',
password: '<hashed_password>',
id: shellOwnerUser!.id,
});
expect(await ownershipService.hasInstanceOwner()).toBeTruthy();
});
it('should recognise ownership creation from api', async () => {
expect(await ownershipService.hasInstanceOwner()).toBeFalsy();
await ownershipService.setupOwner({
firstName: 'TEST',
lastName: 'LN',
password: 'PW',
email: 'EM@em.com',
});
expect(await ownershipService.hasInstanceOwner()).toBeTruthy();
});
});

View File

@@ -1,5 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { SharedCredentials } from '@n8n/db';
import {
Project,
SharedWorkflow,
@@ -12,10 +12,15 @@ import {
GLOBAL_OWNER_ROLE,
PROJECT_OWNER_ROLE,
} from '@n8n/db';
import type { SharedCredentials, SettingsRepository } from '@n8n/db';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { EventService } from '@/events/event.service';
import { OwnershipService } from '@/services/ownership.service';
import { PasswordUtility } from '@/services/password.utility';
import { mockCredential, mockProject } from '@test/mock-objects';
import { CacheService } from '../cache/cache.service';
@@ -25,11 +30,20 @@ describe('OwnershipService', () => {
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const projectRelationRepository = mockInstance(ProjectRelationRepository);
const cacheService = mockInstance(CacheService);
const passwordUtility = mockInstance(PasswordUtility);
const logger = mockInstance(Logger);
const eventService = mock<EventService>();
const settingsRepository = mock<SettingsRepository>();
const ownershipService = new OwnershipService(
cacheService,
userRepository,
eventService,
logger,
passwordUtility,
projectRelationRepository,
sharedWorkflowRepository,
userRepository,
settingsRepository,
);
beforeEach(() => {
@@ -67,7 +81,8 @@ describe('OwnershipService', () => {
owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation();
projectRelation.role = PROJECT_OWNER_ROLE;
(projectRelation.project = project), (projectRelation.user = owner);
projectRelation.project = project;
projectRelation.user = owner;
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
@@ -94,8 +109,9 @@ describe('OwnershipService', () => {
owner.id = uuid();
owner.role = GLOBAL_OWNER_ROLE;
const projectRelation = new ProjectRelation();
projectRelation.role = { slug: PROJECT_OWNER_ROLE_SLUG } as any;
(projectRelation.project = project), (projectRelation.user = owner);
projectRelation.role = PROJECT_OWNER_ROLE;
projectRelation.project = project;
projectRelation.user = owner;
cacheService.getHashValue.mockResolvedValueOnce(owner);
userRepository.create.mockReturnValueOnce(owner);
@@ -226,4 +242,50 @@ describe('OwnershipService', () => {
});
});
});
describe('setupOwner()', () => {
it('should throw a BadRequestError if the instance owner is already setup', async () => {
jest.spyOn(userRepository, 'exists').mockResolvedValueOnce(true);
await expect(ownershipService.setupOwner(mock())).rejects.toThrowError(
new BadRequestError('Instance owner already setup'),
);
expect(userRepository.save).not.toHaveBeenCalled();
expect(eventService.emit).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
'Request to claim instance ownership failed because instance owner already exists',
);
});
it('should setup the instance owner successfully', async () => {
const user = mock<User>({
id: 'userId',
role: GLOBAL_OWNER_ROLE,
authIdentities: [],
});
const payload = {
email: 'valid@email.com',
password: 'NewPassword123',
firstName: 'Jane',
lastName: 'Doe',
};
// not quite perfect as we hash the password.
const expected = { ...user, ...payload, id: 'newUserId' };
userRepository.exists.mockResolvedValueOnce(false);
userRepository.findOneOrFail.mockResolvedValueOnce(user);
userRepository.save.mockResolvedValueOnce(expected);
const actual = await ownershipService.setupOwner(payload);
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
expect(eventService.emit).toHaveBeenCalledWith('instance-owner-setup', {
userId: 'newUserId',
});
expect(actual.id).toEqual('newUserId');
});
});
});

View File

@@ -20,6 +20,7 @@ import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { MfaService } from '@/mfa/mfa.service';
import { OwnershipService } from '@/services/ownership.service';
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
import type { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
import { isApiEnabled } from '@/public-api';
@@ -93,7 +94,7 @@ export type PublicFrontendSettings = {
@Service()
export class FrontendService {
settings: FrontendSettings;
private settings: FrontendSettings;
private communityPackagesService?: CommunityPackagesService;
@@ -113,12 +114,10 @@ export class FrontendService {
private readonly licenseState: LicenseState,
private readonly moduleRegistry: ModuleRegistry,
private readonly mfaService: MfaService,
private readonly ownershipService: OwnershipService,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes();
this.initSettings();
// @TODO: Move to community-packages module
if (Container.get(CommunityPackagesConfig).enabled) {
void import('@/modules/community-packages/community-packages.service').then(
@@ -141,7 +140,7 @@ export class FrontendService {
return envFeatureFlags;
}
private initSettings() {
private async initSettings() {
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
const restEndpoint = this.globalConfig.endpoints.rest;
@@ -230,7 +229,7 @@ export class FrontendService {
defaultLocale: this.globalConfig.defaultLocale,
userManagement: {
quota: this.license.getUsersLimit(),
showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'),
showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()),
smtpSetup: this.mailer.isEmailSetUp,
authenticationMethod: getCurrentAuthenticationMethod(),
},
@@ -374,7 +373,10 @@ export class FrontendService {
this.writeStaticJSON('credentials', credentials);
}
getSettings(): FrontendSettings {
async getSettings(): Promise<FrontendSettings> {
if (!this.settings) {
await this.initSettings();
}
const restEndpoint = this.globalConfig.endpoints.rest;
// Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel`
@@ -390,7 +392,7 @@ export class FrontendService {
Object.assign(this.settings.userManagement, {
quota: this.license.getUsersLimit(),
authenticationMethod: getCurrentAuthenticationMethod(),
showSetupOnFirstLoad: !config.getEnv('userManagement.isInstanceOwnerSetUp'),
showSetupOnFirstLoad: !(await this.ownershipService.hasInstanceOwner()),
});
let dismissedBanners: string[] = [];
@@ -517,7 +519,7 @@ export class FrontendService {
* Only add settings that are absolutely necessary for non-authenticated pages
* @returns Public settings for unauthenticated users
*/
getPublicSettings(): PublicFrontendSettings {
async getPublicSettings(): Promise<PublicFrontendSettings> {
// Get full settings to ensure all required properties are initialized
const {
userManagement: { authenticationMethod, showSetupOnFirstLoad, smtpSetup },
@@ -525,7 +527,7 @@ export class FrontendService {
authCookie,
previewMode,
enterprise: { saml, ldap, oidc },
} = this.getSettings();
} = await this.getSettings();
const publicSettings: PublicFrontendSettings = {
settingsMode: 'public',

View File

@@ -7,19 +7,31 @@ import {
SharedWorkflowRepository,
UserRepository,
Role,
SettingsRepository,
Scope,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { Logger } from '@n8n/backend-common';
import { CacheService } from '@/services/cache/cache.service';
import { OwnerSetupRequestDto } from '@n8n/api-types';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { EventService } from '@/events/event.service';
import { PasswordUtility } from './password.utility';
import { IsNull } from '@n8n/typeorm/find-options/operator/IsNull';
import { Not } from '@n8n/typeorm/find-options/operator/Not';
import config from '@/config';
@Service()
export class OwnershipService {
constructor(
private cacheService: CacheService,
private userRepository: UserRepository,
private eventService: EventService,
private logger: Logger,
private passwordUtility: PasswordUtility,
private projectRelationRepository: ProjectRelationRepository,
private sharedWorkflowRepository: SharedWorkflowRepository,
private userRepository: UserRepository,
private settingsRepository: SettingsRepository,
) {}
// To make use of the cache service we should store POJOs, these
@@ -179,4 +191,64 @@ export class OwnershipService {
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
});
}
async hasInstanceOwner() {
return await this.userRepository.exists({
where: [
{
role: { slug: GLOBAL_OWNER_ROLE.slug },
// We use this to avoid selecting the "shell" user
lastActiveAt: Not(IsNull()),
},
// OR
// This condition only exists because of PAY-4247
{
role: { slug: GLOBAL_OWNER_ROLE.slug },
// We use this to avoid selecting the "shell" user
password: Not(IsNull()),
},
],
relations: ['role'],
});
}
async setupOwner(payload: OwnerSetupRequestDto) {
const { email, firstName, lastName, password } = payload;
if (await this.hasInstanceOwner()) {
this.logger.debug(
'Request to claim instance ownership failed because instance owner already exists',
);
throw new BadRequestError('Instance owner already setup');
}
let shellUser = await this.userRepository.findOneOrFail({
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
relations: ['role'],
});
shellUser.email = email;
shellUser.firstName = firstName;
shellUser.lastName = lastName;
shellUser.lastActiveAt = new Date();
shellUser.password = await this.passwordUtility.hash(password);
shellUser = await this.userRepository.save(shellUser, { transaction: false });
this.logger.info('Owner was set up successfully');
this.eventService.emit('instance-owner-setup', { userId: shellUser.id });
// The next block needs to be deleted and is temporary for now
// See packages/cli/src/config/schema.ts for more info
// We update the SettingsRepository so when we "startup" next time
// the config state is restored.
// #region Delete me
await this.settingsRepository.update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(true) },
);
config.set('userManagement.isInstanceOwnerSetUp', true);
// #endregion
return shellUser;
}
}

View File

@@ -326,7 +326,6 @@ describe('Member', () => {
password: memberPassword,
role: GLOBAL_MEMBER_ROLE,
});
await utils.setInstanceOwnerSetUp(true);
});
test('POST /api-keys should create an api key with no expiration', async () => {

View File

@@ -31,7 +31,6 @@ beforeAll(async () => {
beforeEach(async () => {
await testDb.truncate(['User']);
config.set('ldap.disabled', true);
await utils.setInstanceOwnerSetUp(true);
});
describe('POST /login', () => {

View File

@@ -7,7 +7,6 @@ import {
} from '@n8n/backend-test-utils';
import {
CredentialsEntity,
SettingsRepository,
CredentialsRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
@@ -54,12 +53,6 @@ test('user-management:reset should reset DB to default user state', async () =>
await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())),
);
// mark instance as set up
await Container.get(SettingsRepository).update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: 'true' },
);
//
// ACT
//
@@ -100,9 +93,4 @@ test('user-management:reset should reset DB to default user state', async () =>
await expect(
Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }),
).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]);
// the instance is marked as not set up:
await expect(
Container.get(SettingsRepository).findBy({ key: 'userManagement.isInstanceOwnerSetUp' }),
).resolves.toMatchObject([{ value: 'false' }]);
});

View File

@@ -6,7 +6,6 @@ import { mock } from 'jest-mock-extended';
import { Cipher } from 'n8n-core';
import type { IDataObject } from 'n8n-workflow';
import config from '@/config';
import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
import type { EventService } from '@/events/event.service';
import { License } from '@/license';
@@ -112,7 +111,6 @@ beforeAll(async () => {
authOwnerAgent = testServer.authAgentFor(owner);
const member = await createUser();
authMemberAgent = testServer.authAgentFor(member);
config.set('userManagement.isInstanceOwnerSetUp', true);
Container.set(
ExternalSecretsManager,
new ExternalSecretsManager(

View File

@@ -64,8 +64,6 @@ beforeEach(async () => {
jest.mock('@/telemetry');
config.set('userManagement.isInstanceOwnerSetUp', true);
await setCurrentAuthenticationMethod('email');
});

View File

@@ -144,7 +144,6 @@ describe('Member', () => {
role: { slug: 'global:member' },
});
authMemberAgent = testServer.authAgentFor(member);
await utils.setInstanceOwnerSetUp(true);
});
test('PATCH /me should succeed with valid inputs', async () => {
@@ -286,7 +285,6 @@ describe('Chat User', () => {
role: { slug: 'global:chatUser' },
});
authMemberAgent = testServer.authAgentFor(member);
await utils.setInstanceOwnerSetUp(true);
});
test('PATCH /me should succeed with valid inputs', async () => {

View File

@@ -7,11 +7,10 @@ import {
} from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { GLOBAL_OWNER_ROLE, UserRepository } from '@n8n/db';
import { OwnershipService } from '@/services/ownership.service';
import { Container } from '@n8n/di';
import validator from 'validator';
import config from '@/config';
import { createUserShell } from './shared/db/users';
import * as utils from './shared/utils/';
@@ -21,7 +20,6 @@ let ownerShell: User;
beforeEach(async () => {
ownerShell = await createUserShell(GLOBAL_OWNER_ROLE);
config.set('userManagement.isInstanceOwnerSetUp', false);
});
afterEach(async () => {
@@ -71,10 +69,7 @@ describe('POST /owner/setup', () => {
expect(storedOwner.firstName).toBe(newOwnerData.firstName);
expect(storedOwner.lastName).toBe(newOwnerData.lastName);
const isInstanceOwnerSetUpConfig = config.getEnv('userManagement.isInstanceOwnerSetUp');
expect(isInstanceOwnerSetUpConfig).toBe(true);
const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp();
const isInstanceOwnerSetUpSetting = await Container.get(OwnershipService).hasInstanceOwner();
expect(isInstanceOwnerSetUpSetting).toBe(true);
});

View File

@@ -43,13 +43,14 @@ async function handlePasswordSetup(password: string | null | undefined): Promise
/** Store a new user object, defaulting to a `member` */
export async function newUser(attributes: DeepPartial<User> = {}): Promise<User> {
const { email, password, firstName, lastName, role, ...rest } = attributes;
const { email, password, firstName, lastName, role, lastActiveAt, ...rest } = attributes;
return Container.get(UserRepository).create({
email: email ?? randomEmail(),
password: await handlePasswordSetup(password),
firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(),
role: role ?? GLOBAL_MEMBER_ROLE,
lastActiveAt: lastActiveAt ?? new Date(),
...rest,
});
}

View File

@@ -1,6 +1,6 @@
import type { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { SettingsRepository, WorkflowEntity } from '@n8n/db';
import { WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import {
@@ -25,7 +25,6 @@ import type { INodeTypeData, INode } from 'n8n-workflow';
import type request from 'supertest';
import { v4 as uuid } from 'uuid';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import { ExecutionService } from '@/executions/execution.service';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@@ -159,27 +158,6 @@ export function getAuthToken(response: request.Response, authCookieName = AUTH_C
return match.groups.token;
}
// ----------------------------------
// settings
// ----------------------------------
export async function isInstanceOwnerSetUp() {
const { value } = await Container.get(SettingsRepository).findOneByOrFail({
key: 'userManagement.isInstanceOwnerSetUp',
});
return Boolean(value);
}
export const setInstanceOwnerSetUp = async (value: boolean) => {
config.set('userManagement.isInstanceOwnerSetUp', value);
await Container.get(SettingsRepository).update(
{ key: 'userManagement.isInstanceOwnerSetUp' },
{ value: JSON.stringify(value) },
);
};
// ----------------------------------
// community nodes
// ----------------------------------

View File

@@ -10,7 +10,6 @@ import request from 'supertest';
import { URL } from 'url';
import { AuthService } from '@/auth/auth.service';
import config from '@/config';
import { AUTH_COOKIE_NAME } from '@/constants';
import { ControllerRegistry } from '@/controller.registry';
import { License } from '@/license';
@@ -129,7 +128,6 @@ export const setupTestServer = ({
await testDb.init();
Container.get(GlobalConfig).userManagement.jwtSecret = 'My JWT secret';
config.set('userManagement.isInstanceOwnerSetUp', true);
testServer.license.mock(Container.get(License));
testServer.license.mockLicenseState(Container.get(LicenseState));

View File

@@ -2,7 +2,7 @@ import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.describe('04 - Credentials', () => {
test.describe('Credentials', () => {
test('composer: createFromList creates credential', async ({ n8n }) => {
const projectId = await n8n.start.fromNewProject();
const credentialName = `credential-${nanoid()}`;

View File

@@ -1,6 +1,6 @@
import { test, expect } from '../../../fixtures/base';
test.describe('03 - Node Details Configuration', () => {
test.describe('Node Details Configuration', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});

View File

@@ -1,4 +1,4 @@
import { expect, test } from '../../fixtures/base';
import { expect, test } from '../../../fixtures/base';
test.describe('User API Service', () => {
test('should create a user with default values', async ({ api }) => {

View File

@@ -1,6 +1,6 @@
import { test, expect } from '../../../fixtures/base';
test.describe('01 - UI Test Entry Points', () => {
test.describe('UI Test Entry Points', () => {
test.describe('Entry Point: Home Page', () => {
test('should navigate from home', async ({ n8n }) => {
await n8n.start.fromHome();

View File

@@ -0,0 +1,175 @@
import { test, expect } from '../../../fixtures/base';
const OWNER_EMAIL = 'nathan@n8n.io';
const ADMIN_EMAIL = 'admin@n8n.io';
const MEMBER_0_EMAIL = 'member@n8n.io'; // U2
test.describe('@isolated', () => {
test.describe('Credential Usage in Cross Shared Workflows', () => {
test.beforeEach(async ({ n8n, api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
});
test('should only show credentials from the same team project', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
const devProject = await n8n.projectComposer.createProject('Development');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: devProject.projectId },
);
const testProject = await n8n.projectComposer.createProject('Test');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: testProject.projectId },
);
await n8n.projectTabs.clickWorkflowsTab();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only Test project credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test('should only show credentials in their personal project for members', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.addUserToSharing(MEMBER_0_EMAIL);
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Own credential and shared credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(2);
});
test('should only show credentials in their personal project for members if the workflow was shared with them', async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(MEMBER_0_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only own credential visible (not owner's)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test("should show all credentials from all personal projects the workflow's been shared into for the global owner", async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.api.signin('member', 1);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('admin');
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(OWNER_EMAIL);
await n8n.workflowSharingModal.addUser(ADMIN_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees 3 credentials: admin's, U2's, owner's
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(3);
});
test('should show all personal credentials if the global owner owns the workflow', async ({
n8n,
}) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('owner');
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees member's credential (global owner privilege)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from '../../fixtures/base';
import { test, expect } from '../../../fixtures/base';
const OWNER_EMAIL = 'nathan@n8n.io';
const ADMIN_EMAIL = 'admin@n8n.io';
@@ -302,172 +302,4 @@ test.describe('@isolated', () => {
).toHaveCount(1);
});
});
test.describe('Credential Usage in Cross Shared Workflows', () => {
test.beforeEach(async ({ n8n, api }) => {
await api.resetDatabase();
await api.enableFeature('sharing');
await api.enableFeature('advancedPermissions');
await api.enableFeature('projectRole:admin');
await api.enableFeature('projectRole:editor');
await api.setMaxTeamProjectsQuota(-1);
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
});
test('should only show credentials from the same team project', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
const devProject = await n8n.projectComposer.createProject('Development');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: devProject.projectId },
);
const testProject = await n8n.projectComposer.createProject('Test');
await n8n.projectTabs.clickCredentialsTab();
await n8n.credentialsComposer.createFromList(
'Notion API',
{ apiKey: 'test' },
{ projectId: testProject.projectId },
);
await n8n.projectTabs.clickWorkflowsTab();
await n8n.workflows.clickNewWorkflowCard();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only Test project credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test('should only show credentials in their personal project for members', async ({ n8n }) => {
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.changeTab('Sharing');
await n8n.credentials.credentialModal.addUserToSharing(MEMBER_0_EMAIL);
await n8n.credentials.credentialModal.saveSharing();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Own credential and shared credential visible
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(2);
});
test('should only show credentials in their personal project for members if the workflow was shared with them', async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(MEMBER_0_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Only own credential visible (not owner's)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
test("should show all credentials from all personal projects the workflow's been shared into for the global owner", async ({
n8n,
}) => {
const workflowName = 'Test workflow';
await n8n.api.signin('member', 1);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('admin');
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflow('new');
await n8n.canvas.setWorkflowName(workflowName);
await n8n.page.keyboard.press('Enter');
await n8n.canvas.openShareModal();
await n8n.workflowSharingModal.addUser(OWNER_EMAIL);
await n8n.workflowSharingModal.addUser(ADMIN_EMAIL);
await n8n.workflowSharingModal.save();
await n8n.api.signin('owner');
await n8n.navigate.toCredentials();
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
await n8n.navigate.toWorkflows();
await n8n.workflows.cards.getWorkflow(workflowName).click();
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees 3 credentials: admin's, U2's, owner's
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(3);
});
test('should show all personal credentials if the global owner owns the workflow', async ({
n8n,
}) => {
await n8n.api.signin('member', 0);
await n8n.navigate.toCredentials();
await n8n.credentials.addResource.credential();
await n8n.credentials.selectCredentialType('Notion API');
await n8n.credentials.credentialModal.fillField('apiKey', 'test');
await n8n.credentials.credentialModal.save();
await n8n.credentials.credentialModal.close();
await n8n.api.signin('owner');
await n8n.navigate.toWorkflow('new');
await n8n.canvas.addNode('Notion');
await n8n.canvas.getFirstAction().click();
// Owner sees member's credential (global owner privilege)
await n8n.ndv.getNodeCredentialsSelect().click();
await expect(n8n.ndv.getVisiblePopper().locator('li')).toHaveCount(1);
});
});
});

View File

@@ -1,581 +0,0 @@
import fs from 'fs';
import { nanoid } from 'nanoid';
import {
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../../config/constants';
import { test, expect } from '../../../../fixtures/base';
import type { n8nPage } from '../../../../pages/n8nPage';
import { resolveFromRoot } from '../../../../utils/path-helper';
async function saveWorkflowAndGetId(n8n: n8nPage): Promise<string> {
const saveResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') &&
(response.request().method() === 'POST' || response.request().method() === 'PATCH'),
);
await n8n.canvas.saveWorkflow();
const saveResponse = await saveResponsePromise;
const {
data: { id },
} = await saveResponse.json();
return id;
}
async function goToWorkflow(n8n: n8nPage, workflowId: string): Promise<void> {
const loadResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes(`/rest/workflows/${workflowId}`) &&
response.request().method() === 'GET' &&
response.status() === 200,
);
await n8n.page.goto(`/workflow/${workflowId}`);
await loadResponsePromise;
}
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Actions', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should be able to save on button click', async ({ n8n }) => {
const saveButton = n8n.canvas.getWorkflowSaveButton();
await expect(saveButton).toContainText('Save');
await n8n.canvas.saveWorkflow();
await expect(saveButton).toContainText('Saved');
const tagName = await saveButton.evaluate((el) => el.tagName);
expect(tagName).toBe('SPAN');
await expect(n8n.page).not.toHaveURL(/\/workflow\/new$/);
await expect(n8n.page).toHaveURL(/\/workflow\/[a-zA-Z0-9]+$/);
});
test('should save workflow on keyboard shortcut', async ({ n8n }) => {
const saveButton = n8n.canvas.getWorkflowSaveButton();
await n8n.canvas.deselectAll();
await n8n.canvas.hitSaveWorkflow();
await expect(saveButton).toContainText('Saved');
const tagName = await saveButton.evaluate((el) => el.tagName);
expect(tagName).toBe('SPAN');
});
test('should not save already saved workflow', async ({ n8n }) => {
const patchRequests: string[] = [];
n8n.page.on('request', (request) => {
if (request.method() === 'PATCH' && request.url().includes('/rest/workflows/')) {
patchRequests.push(request.url());
}
});
const saveButton = n8n.canvas.getWorkflowSaveButton();
await expect(saveButton).toContainText('Save');
await n8n.canvas.saveWorkflow();
await expect(saveButton).toContainText('Saved');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
const patchPromise = n8n.page.waitForRequest(
(req) => req.method() === 'PATCH' && req.url().includes('/rest/workflows/'),
);
await n8n.canvas.saveWorkflow();
await patchPromise;
await expect(saveButton).toContainText('Saved');
expect(await saveButton.evaluate((el) => el.tagName)).toBe('SPAN');
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
expect(patchRequests).toHaveLength(1);
});
test('should not be able to publish workflow without trigger node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
});
test('should be able to publish workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
test('should not be able to publish workflow when nodes have errors', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.saveWorkflow();
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
await expect(n8n.canvas.getPublishModalCallout()).toBeVisible();
});
test('should be able to publish workflow when nodes with errors are disabled', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
await n8n.canvas.cancelPublishWorkflowModal();
const nodeName = await n8n.canvas.getCanvasNodes().last().getAttribute('data-node-name');
await n8n.canvas.toggleNodeEnabled(nodeName!);
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
test('should save new workflow after renaming', async ({ n8n }) => {
await n8n.canvas.setWorkflowName('Something else');
await n8n.canvas.getWorkflowNameInput().press('Enter');
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
});
test('should rename workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
await n8n.canvas.setWorkflowName('Something else');
await n8n.canvas.getWorkflowNameInput().press('Enter');
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
await expect(n8n.canvas.getWorkflowName()).toHaveAttribute('title', 'Something else');
});
test('should not save workflow if canvas is loading', async ({ n8n }) => {
let patchCount = 0;
n8n.page.on('request', (req) => {
if (req.method() === 'PATCH' && req.url().includes('/rest/workflows/')) {
patchCount++;
}
});
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
const workflowId = n8n.canvas.getWorkflowIdFromUrl();
await n8n.canvasComposer.delayWorkflowLoad(workflowId);
await n8n.page.reload();
await expect(n8n.canvas.getLoadingMask()).toBeVisible();
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
expect(patchCount).toBe(0);
await expect(n8n.page.getByTestId('node-view-loader')).not.toBeAttached();
await expect(n8n.canvas.getLoadingMask()).not.toBeAttached();
await n8n.canvasComposer.undelayWorkflowLoad(workflowId);
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const patchPromise = n8n.page.waitForRequest(
(req) => req.method() === 'PATCH' && req.url().includes('/rest/workflows/'),
);
await n8n.canvas.hitSaveWorkflow();
await patchPromise;
expect(patchCount).toBe(1);
});
test('should not save workflow twice when save is in progress', async ({ n8n }) => {
const oldName = await n8n.canvas.getWorkflowNameInput().inputValue();
await n8n.canvas.getWorkflowName().click();
await n8n.canvas.getWorkflowNameInput().press('ControlOrMeta+a');
await n8n.canvas.getWorkflowNameInput().pressSequentially('Test');
await n8n.canvas.getWorkflowSaveButton().click();
await expect(n8n.canvas.getWorkflowNameInput()).toHaveValue('Test');
await n8n.navigate.toHome();
await expect(n8n.workflows.cards.getWorkflow(oldName)).toBeHidden();
});
test('should copy nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeCreator.getRoot()).not.toBeAttached();
await n8n.clipboard.grant();
await n8n.canvas.selectAll();
await n8n.canvas.copyNodes();
await n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
const clipboardText = await n8n.clipboard.readText();
const copiedWorkflow = JSON.parse(clipboardText);
expect(copiedWorkflow.nodes).toHaveLength(2);
});
test('should paste nodes (both current and old node versions)', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_paste-data.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await expect(n8n.canvas.nodeConnections()).toHaveCount(5);
});
test('should allow importing nodes without names', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_import_nodes_empty_name.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
const nodes = n8n.canvas.getCanvasNodes();
const count = await nodes.count();
for (let i = 0; i < count; i++) {
await expect(nodes.nth(i)).toHaveAttribute('data-node-name');
}
});
test('should update workflow settings', async ({ n8n }) => {
await n8n.navigate.toHome();
const workflowsResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') && response.request().method() === 'GET',
);
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
const workflowsResponse = await workflowsResponsePromise;
const responseBody = await workflowsResponse.json();
const totalWorkflows = responseBody.count;
await n8n.canvas.saveWorkflow();
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
await n8n.workflowSettingsModal.getErrorWorkflowField().click();
const optionCount = await n8n.page.getByRole('option').count();
expect(optionCount).toBeGreaterThanOrEqual(totalWorkflows + 2);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimezoneField().click();
await expect(n8n.page.getByRole('option').first()).toBeVisible();
await n8n.page.getByRole('option').nth(1).click();
await n8n.workflowSettingsModal.getSaveFailedExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveSuccessExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveManualExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveExecutionProgressField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimeoutSwitch().click();
await n8n.workflowSettingsModal.getTimeoutInput().fill('1');
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.workflowSettingsModal.getModal()).toBeHidden();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
});
test('should not be able to archive or delete unsaved workflow', async ({ n8n }) => {
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getDeleteMenuItem()).toBeHidden();
await expect(n8n.workflowSettingsModal.getArchiveMenuItem().locator('..')).toHaveClass(
/is-disabled/,
);
});
test('should archive nonactive workflow and then delete it', async ({ n8n }) => {
const workflowId = await saveWorkflowAndGetId(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode
test.skip('should archive published workflow and then delete it', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await saveWorkflowAndGetId(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
test('should archive nonactive workflow and then unarchive it', async ({ n8n }) => {
const workflowId = await saveWorkflowAndGetId(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.canvas.getNodeCreatorPlusButton()).toBeVisible();
});
test('should not show unpublish menu item for non-published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getUnpublishMenuItem()).not.toBeAttached();
});
// TODO: flaky test - 18 similar failures across 10 branches in last 14 days
test.skip('should unpublish a published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnpublishMenuItem();
await expect(n8n.workflowSettingsModal.getUnpublishModal()).toBeVisible();
await n8n.workflowSettingsModal.confirmUnpublishModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode
test.skip('should unpublish published workflow on archive', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await saveWorkflowAndGetId(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishButton()).not.toBeVisible();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeVisible();
});
test.describe('duplicate workflow', () => {
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
test.beforeEach(async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
});
test('should duplicate unsaved workflow', async ({ n8n }) => {
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('should duplicate saved workflow', async ({ n8n }) => {
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
});
test('should keep endpoint click working when switching between execution and editor tab', async ({
n8n,
}) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickExecutionsTab();
await n8n.page.waitForURL(/\/executions/);
await n8n.canvas.clickEditorTab();
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
});
test('should run workflow on button click', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should run workflow using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.hitExecuteWorkflow();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should not run empty workflows', async ({ n8n }) => {
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await expect(n8n.canvas.getExecuteWorkflowButton()).not.toBeAttached();
await n8n.canvas.hitExecuteWorkflow();
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(0);
});
test.describe('Menu entry Push To Git', () => {
test('should not show up in the menu for members @auth:member', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).not.toBeAttached();
});
test('should show up for owners @auth:owner', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).toBeVisible();
});
});
});

View File

@@ -0,0 +1,194 @@
import { SCHEDULE_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
async function saveWorkflowAndGetId(n8n: n8nPage): Promise<string> {
const saveResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') &&
(response.request().method() === 'POST' || response.request().method() === 'PATCH'),
);
await n8n.canvas.saveWorkflow();
const saveResponse = await saveResponsePromise;
const {
data: { id },
} = await saveResponse.json();
return id;
}
async function goToWorkflow(n8n: n8nPage, workflowId: string): Promise<void> {
const loadResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes(`/rest/workflows/${workflowId}`) &&
response.request().method() === 'GET' &&
response.status() === 200,
);
await n8n.page.goto(`/workflow/${workflowId}`);
await loadResponsePromise;
}
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Archive', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should not be able to archive or delete unsaved workflow', async ({ n8n }) => {
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getDeleteMenuItem()).toBeHidden();
await expect(n8n.workflowSettingsModal.getArchiveMenuItem().locator('..')).toHaveClass(
/is-disabled/,
);
});
test('should archive nonactive workflow and then delete it', async ({ n8n }) => {
const workflowId = await saveWorkflowAndGetId(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode
test.skip('should archive published workflow and then delete it', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await saveWorkflowAndGetId(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickDeleteMenuItem();
await n8n.workflowSettingsModal.confirmDeleteModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
});
test('should archive nonactive workflow and then unarchive it', async ({ n8n }) => {
const workflowId = await saveWorkflowAndGetId(n8n);
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getNodeCreatorPlusButton()).not.toBeAttached();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await expect(n8n.canvas.getNodeCreatorPlusButton()).toBeVisible();
});
test('should not show unpublish menu item for non-published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getUnpublishMenuItem()).not.toBeAttached();
});
// TODO: flaky test - 18 similar failures across 10 branches in last 14 days
test.skip('should unpublish a published workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnpublishMenuItem();
await expect(n8n.workflowSettingsModal.getUnpublishModal()).toBeVisible();
await n8n.workflowSettingsModal.confirmUnpublishModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests -- Flaky in multi-main mode
test.skip('should unpublish published workflow on archive', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const workflowId = await saveWorkflowAndGetId(n8n);
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickArchiveMenuItem();
await n8n.workflowSettingsModal.confirmArchiveModal();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.page).toHaveURL(/\/workflows$/);
await goToWorkflow(n8n, workflowId);
await expect(n8n.canvas.getArchivedTag()).toBeVisible();
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await expect(n8n.canvas.getPublishButton()).not.toBeVisible();
await expect(n8n.workflowSettingsModal.getWorkflowMenu()).toBeVisible();
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await n8n.workflowSettingsModal.clickUnarchiveMenuItem();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await expect(n8n.canvas.getArchivedTag()).not.toBeAttached();
await n8n.canvas.publishWorkflow();
await n8n.page.keyboard.press('Escape');
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
await expect(n8n.canvas.getOpenPublishModalButton()).toBeVisible();
});
});

View File

@@ -0,0 +1,66 @@
import fs from 'fs';
import { CODE_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import { resolveFromRoot } from '../../../../../utils/path-helper';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Copy Paste', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should copy nodes', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(2);
await expect(n8n.canvas.nodeCreator.getRoot()).not.toBeAttached();
await n8n.clipboard.grant();
await n8n.canvas.selectAll();
await n8n.canvas.copyNodes();
await n8n.notifications.waitForNotificationAndClose('Copied to clipboard');
const clipboardText = await n8n.clipboard.readText();
const copiedWorkflow = JSON.parse(clipboardText);
expect(copiedWorkflow.nodes).toHaveLength(2);
});
test('should paste nodes (both current and old node versions)', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_paste-data.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(5);
await expect(n8n.canvas.nodeConnections()).toHaveCount(5);
});
test('should allow importing nodes without names', async ({ n8n }) => {
const workflowJson = fs.readFileSync(
resolveFromRoot('workflows', 'Test_workflow-actions_import_nodes_empty_name.json'),
'utf-8',
);
await n8n.canvas.canvasPane().click();
await n8n.clipboard.paste(workflowJson);
await n8n.canvas.clickZoomToFitButton();
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(3);
await expect(n8n.canvas.nodeConnections()).toHaveCount(2);
const nodes = n8n.canvas.getCanvasNodes();
const count = await nodes.count();
for (let i = 0; i < count; i++) {
await expect(nodes.nth(i)).toHaveAttribute('data-node-name');
}
});
});

View File

@@ -0,0 +1,31 @@
import { nanoid } from 'nanoid';
import { MANUAL_TRIGGER_NODE_NAME } from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Duplicate', () => {
const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow';
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
});
test('should duplicate unsaved workflow', async ({ n8n }) => {
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
test('should duplicate saved workflow', async ({ n8n }) => {
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
const uniqueTag = `Duplicate-${nanoid(6)}`;
await n8n.workflowComposer.duplicateWorkflow(DUPLICATE_WORKFLOW_NAME, uniqueTag);
await expect(n8n.notifications.getErrorNotifications()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,63 @@
import {
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Publish', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should not be able to publish workflow without trigger node', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
});
test('should be able to publish workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await expect(n8n.canvas.getPublishedIndicator()).not.toBeVisible();
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
test('should not be able to publish workflow when nodes have errors', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.saveWorkflow();
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
await expect(n8n.canvas.getPublishModalCallout()).toBeVisible();
});
test('should be able to publish workflow when nodes with errors are disabled', async ({
n8n,
}) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.addNode(NOTION_NODE_NAME, { action: 'Append a block', closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
await n8n.canvas.getOpenPublishModalButton().click();
await expect(n8n.canvas.getPublishButton()).toBeDisabled();
await n8n.canvas.cancelPublishWorkflowModal();
const nodeName = await n8n.canvas.getCanvasNodes().last().getAttribute('data-node-name');
await n8n.canvas.toggleNodeEnabled(nodeName!);
await n8n.canvas.publishWorkflow();
await expect(n8n.canvas.getPublishedIndicator()).toBeVisible();
});
});

View File

@@ -0,0 +1,61 @@
import {
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Run', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should keep endpoint click working when switching between execution and editor tab', async ({
n8n,
}) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
await n8n.page.keyboard.press('Escape');
await n8n.canvas.clickExecutionsTab();
await n8n.page.waitForURL(/\/executions/);
await n8n.canvas.clickEditorTab();
await n8n.canvas.clickNodePlusEndpoint('Edit Fields');
await expect(n8n.canvas.nodeCreatorSearchBar()).toBeVisible();
});
test('should run workflow on button click', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should run workflow using keyboard shortcut', async ({ n8n }) => {
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await n8n.canvas.hitExecuteWorkflow();
await expect(
n8n.notifications.getNotificationByTitle('Workflow executed successfully'),
).toBeVisible();
});
test('should not run empty workflows', async ({ n8n }) => {
await expect(n8n.canvas.getCanvasNodes()).toHaveCount(0);
await expect(n8n.canvas.getExecuteWorkflowButton()).not.toBeAttached();
await n8n.canvas.hitExecuteWorkflow();
await expect(n8n.notifications.getSuccessNotifications()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,146 @@
import {
MANUAL_TRIGGER_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Save', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should be able to save on button click', async ({ n8n }) => {
const saveButton = n8n.canvas.getWorkflowSaveButton();
await expect(saveButton).toContainText('Save');
await n8n.canvas.saveWorkflow();
await expect(saveButton).toContainText('Saved');
const tagName = await saveButton.evaluate((el) => el.tagName);
expect(tagName).toBe('SPAN');
await expect(n8n.page).not.toHaveURL(/\/workflow\/new$/);
await expect(n8n.page).toHaveURL(/\/workflow\/[a-zA-Z0-9]+$/);
});
test('should save workflow on keyboard shortcut', async ({ n8n }) => {
const saveButton = n8n.canvas.getWorkflowSaveButton();
await n8n.canvas.deselectAll();
await n8n.canvas.hitSaveWorkflow();
await expect(saveButton).toContainText('Saved');
const tagName = await saveButton.evaluate((el) => el.tagName);
expect(tagName).toBe('SPAN');
});
test('should not save already saved workflow', async ({ n8n }) => {
const patchRequests: string[] = [];
n8n.page.on('request', (request) => {
if (request.method() === 'PATCH' && request.url().includes('/rest/workflows/')) {
patchRequests.push(request.url());
}
});
const saveButton = n8n.canvas.getWorkflowSaveButton();
await expect(saveButton).toContainText('Save');
await n8n.canvas.saveWorkflow();
await expect(saveButton).toContainText('Saved');
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
const patchPromise = n8n.page.waitForRequest(
(req) => req.method() === 'PATCH' && req.url().includes('/rest/workflows/'),
);
await n8n.canvas.saveWorkflow();
await patchPromise;
await expect(saveButton).toContainText('Saved');
expect(await saveButton.evaluate((el) => el.tagName)).toBe('SPAN');
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
expect(patchRequests).toHaveLength(1);
});
test('should save new workflow after renaming', async ({ n8n }) => {
await n8n.canvas.setWorkflowName('Something else');
await n8n.canvas.getWorkflowNameInput().press('Enter');
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
});
test('should rename workflow', async ({ n8n }) => {
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
await n8n.canvas.setWorkflowName('Something else');
await n8n.canvas.getWorkflowNameInput().press('Enter');
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
await expect(n8n.canvas.getWorkflowName()).toHaveAttribute('title', 'Something else');
});
test('should not save workflow if canvas is loading', async ({ n8n }) => {
let patchCount = 0;
n8n.page.on('request', (req) => {
if (req.method() === 'PATCH' && req.url().includes('/rest/workflows/')) {
patchCount++;
}
});
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
await n8n.canvas.saveWorkflow();
await expect(n8n.canvas.getWorkflowSaveButton()).toContainText('Saved');
const workflowId = n8n.canvas.getWorkflowIdFromUrl();
await n8n.canvasComposer.delayWorkflowLoad(workflowId);
await n8n.page.reload();
await expect(n8n.canvas.getLoadingMask()).toBeVisible();
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
await n8n.canvas.hitSaveWorkflow();
expect(patchCount).toBe(0);
await expect(n8n.page.getByTestId('node-view-loader')).not.toBeAttached();
await expect(n8n.canvas.getLoadingMask()).not.toBeAttached();
await n8n.canvasComposer.undelayWorkflowLoad(workflowId);
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME, { closeNDV: true });
const patchPromise = n8n.page.waitForRequest(
(req) => req.method() === 'PATCH' && req.url().includes('/rest/workflows/'),
);
await n8n.canvas.hitSaveWorkflow();
await patchPromise;
expect(patchCount).toBe(1);
});
test('should not save workflow twice when save is in progress', async ({ n8n }) => {
const oldName = await n8n.canvas.getWorkflowNameInput().inputValue();
await n8n.canvas.getWorkflowName().click();
await n8n.canvas.getWorkflowNameInput().press('ControlOrMeta+a');
await n8n.canvas.getWorkflowNameInput().pressSequentially('Test');
await n8n.canvas.getWorkflowSaveButton().click();
await expect(n8n.canvas.getWorkflowNameInput()).toHaveValue('Test');
await n8n.navigate.toHome();
await expect(n8n.workflows.cards.getWorkflow(oldName)).toBeHidden();
});
});

View File

@@ -0,0 +1,73 @@
import { test, expect } from '../../../../../fixtures/base';
// eslint-disable-next-line playwright/no-skipped-test
test.skip('Workflow Settings', () => {
test.beforeEach(async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
});
test('should update workflow settings', async ({ n8n }) => {
await n8n.navigate.toHome();
const workflowsResponsePromise = n8n.page.waitForResponse(
(response) =>
response.url().includes('/rest/workflows') && response.request().method() === 'GET',
);
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
const workflowsResponse = await workflowsResponsePromise;
const responseBody = await workflowsResponse.json();
const totalWorkflows = responseBody.count;
await n8n.canvas.saveWorkflow();
await n8n.workflowSettingsModal.open();
await expect(n8n.workflowSettingsModal.getModal()).toBeVisible();
await n8n.workflowSettingsModal.getErrorWorkflowField().click();
const optionCount = await n8n.page.getByRole('option').count();
expect(optionCount).toBeGreaterThanOrEqual(totalWorkflows + 2);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimezoneField().click();
await expect(n8n.page.getByRole('option').first()).toBeVisible();
await n8n.page.getByRole('option').nth(1).click();
await n8n.workflowSettingsModal.getSaveFailedExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveSuccessExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveManualExecutionsField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getSaveExecutionProgressField().click();
await expect(n8n.page.getByRole('option')).toHaveCount(3);
await n8n.page.getByRole('option').last().click();
await n8n.workflowSettingsModal.getTimeoutSwitch().click();
await n8n.workflowSettingsModal.getTimeoutInput().fill('1');
await n8n.workflowSettingsModal.clickSave();
await expect(n8n.workflowSettingsModal.getModal()).toBeHidden();
await expect(n8n.notifications.getSuccessNotifications().first()).toBeVisible();
});
test.describe('Menu entry Push To Git', () => {
test('should not show up in the menu for members @auth:member', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).not.toBeAttached();
});
test('should show up for owners @auth:owner', async ({ n8n }) => {
await n8n.workflowSettingsModal.getWorkflowMenu().click();
await expect(n8n.workflowSettingsModal.getPushToGitMenuItem()).toBeVisible();
});
});
});