mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
Merge branch 'master' into pay-4195-replace-old-popover-component
This commit is contained in:
2
.github/workflows/storybook.yml
vendored
2
.github/workflows/storybook.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -31,7 +31,6 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['User']);
|
||||
config.set('ldap.disabled', true);
|
||||
await utils.setInstanceOwnerSetUp(true);
|
||||
});
|
||||
|
||||
describe('POST /login', () => {
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -64,8 +64,6 @@ beforeEach(async () => {
|
||||
|
||||
jest.mock('@/telemetry');
|
||||
|
||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||
|
||||
await setCurrentAuthenticationMethod('email');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ----------------------------------
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()}`;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }) => {
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user