mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat: Reduce unauthentication information in settings endpoint (#21133)
This commit is contained in:
@@ -31,7 +31,37 @@ export interface IUserManagementSettings {
|
||||
authenticationMethod: AuthenticationMethod;
|
||||
}
|
||||
|
||||
export interface IEnterpriseSettings {
|
||||
sharing: boolean;
|
||||
ldap: boolean;
|
||||
saml: boolean;
|
||||
oidc: boolean;
|
||||
mfaEnforcement: boolean;
|
||||
logStreaming: boolean;
|
||||
advancedExecutionFilters: boolean;
|
||||
variables: boolean;
|
||||
sourceControl: boolean;
|
||||
auditLogs: boolean;
|
||||
externalSecrets: boolean;
|
||||
showNonProdBanner: boolean;
|
||||
debugInEditor: boolean;
|
||||
binaryDataS3: boolean;
|
||||
workflowHistory: boolean;
|
||||
workerView: boolean;
|
||||
advancedPermissions: boolean;
|
||||
apiKeyScopes: boolean;
|
||||
workflowDiffs: boolean;
|
||||
provisioning: boolean;
|
||||
projects: {
|
||||
team: {
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
customRoles: boolean;
|
||||
}
|
||||
|
||||
export interface FrontendSettings {
|
||||
settingsMode?: 'public' | 'authenticated';
|
||||
inE2ETests: boolean;
|
||||
isDocker: boolean;
|
||||
databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb';
|
||||
@@ -145,34 +175,7 @@ export interface FrontendSettings {
|
||||
builtIn?: string[];
|
||||
external?: string[];
|
||||
};
|
||||
enterprise: {
|
||||
sharing: boolean;
|
||||
ldap: boolean;
|
||||
saml: boolean;
|
||||
oidc: boolean;
|
||||
mfaEnforcement: boolean;
|
||||
logStreaming: boolean;
|
||||
advancedExecutionFilters: boolean;
|
||||
variables: boolean;
|
||||
sourceControl: boolean;
|
||||
auditLogs: boolean;
|
||||
externalSecrets: boolean;
|
||||
showNonProdBanner: boolean;
|
||||
debugInEditor: boolean;
|
||||
binaryDataS3: boolean;
|
||||
workflowHistory: boolean;
|
||||
workerView: boolean;
|
||||
advancedPermissions: boolean;
|
||||
apiKeyScopes: boolean;
|
||||
workflowDiffs: boolean;
|
||||
provisioning: boolean;
|
||||
projects: {
|
||||
team: {
|
||||
limit: number;
|
||||
};
|
||||
};
|
||||
customRoles: boolean;
|
||||
};
|
||||
enterprise: IEnterpriseSettings;
|
||||
hideUsagePage: boolean;
|
||||
license: {
|
||||
planName?: string;
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('authMiddleware', () => {
|
||||
describe('createAuthMiddleware', () => {
|
||||
const mockReq = () =>
|
||||
mock<AuthenticatedRequest>({
|
||||
cookies: {},
|
||||
@@ -331,6 +331,180 @@ describe('AuthService', () => {
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowUnauthenticated', () => {
|
||||
it('should populate the user info if the token is valid', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||
userRepository.findOne.mockResolvedValue(user);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).toHaveBeenCalled();
|
||||
expect(req.user).toBe(user);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the cookie if the token is expired', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||
jest.advanceTimersByTime(365 * Time.days.toMilliseconds);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
||||
});
|
||||
|
||||
it('should clear the cookie if the token has been invalidated', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = validToken;
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(true);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
||||
});
|
||||
|
||||
it('should not populate the user info if the token is invalid', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = 'invalid-token';
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
||||
});
|
||||
|
||||
it('should not populate the user info if the token is not set', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = undefined;
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear cookie if MFA required and not used', async () => {
|
||||
const userWithMfa = mock<User>({ ...userData, mfaEnabled: true, mfaSecret: 'secret' });
|
||||
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false
|
||||
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||
userRepository.findOne.mockResolvedValue(userWithMfa);
|
||||
mfaService.isMFAEnforced.mockReturnValue(true);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.clearCookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME);
|
||||
});
|
||||
|
||||
it('should skip user when MFA enforced and user has no MFA', async () => {
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = validToken; // validToken has usedMfa: false
|
||||
|
||||
invalidAuthTokenRepository.existsBy.mockResolvedValue(false);
|
||||
userRepository.findOne.mockResolvedValue(user); // user has mfaEnabled: false
|
||||
mfaService.isMFAEnforced.mockReturnValue(true);
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).toHaveBeenCalled();
|
||||
expect(userRepository.findOne).toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work correctly when both allowUnauthenticated and allowSkipPreviewAuth are true in preview mode', async () => {
|
||||
const originalPreviewMode = process.env.N8N_PREVIEW_MODE;
|
||||
process.env.N8N_PREVIEW_MODE = 'true';
|
||||
|
||||
const req = mockReq();
|
||||
req.cookies[AUTH_COOKIE_NAME] = undefined;
|
||||
|
||||
const middleware = authService.createAuthMiddleware({
|
||||
allowSkipMFA: false,
|
||||
allowUnauthenticated: true,
|
||||
allowSkipPreviewAuth: true,
|
||||
});
|
||||
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(invalidAuthTokenRepository.existsBy).not.toHaveBeenCalled();
|
||||
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original value
|
||||
if (originalPreviewMode === undefined) {
|
||||
delete process.env.N8N_PREVIEW_MODE;
|
||||
} else {
|
||||
process.env.N8N_PREVIEW_MODE = originalPreviewMode;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueCookie', () => {
|
||||
|
||||
@@ -47,6 +47,12 @@ interface CreateAuthMiddlewareOptions {
|
||||
* If true, authentication becomes optional in preview mode
|
||||
*/
|
||||
allowSkipPreviewAuth?: boolean;
|
||||
/**
|
||||
* If true, the middleware will not throw an error if authentication fails
|
||||
* and will instead call next() regardless of authentication status.
|
||||
* Use this for endpoints that should return different data for authenticated vs unauthenticated users.
|
||||
*/
|
||||
allowUnauthenticated?: boolean;
|
||||
}
|
||||
|
||||
@Service()
|
||||
@@ -83,21 +89,32 @@ export class AuthService {
|
||||
];
|
||||
}
|
||||
|
||||
createAuthMiddleware({ allowSkipMFA, allowSkipPreviewAuth }: CreateAuthMiddlewareOptions) {
|
||||
createAuthMiddleware({
|
||||
allowSkipMFA,
|
||||
allowSkipPreviewAuth,
|
||||
allowUnauthenticated,
|
||||
}: CreateAuthMiddlewareOptions) {
|
||||
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
const token = req.cookies[AUTH_COOKIE_NAME];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const isInvalid = await this.invalidAuthTokenRepository.existsBy({ token });
|
||||
if (isInvalid) throw new AuthError('Unauthorized');
|
||||
|
||||
const [user, { usedMfa }] = await this.resolveJwt(token, req, res);
|
||||
const mfaEnforced = this.mfaService.isMFAEnforced();
|
||||
|
||||
if (mfaEnforced && !usedMfa && !allowSkipMFA) {
|
||||
// If MFA is enforced, we need to check if the user has MFA enabled and used it during authentication
|
||||
if (user.mfaEnabled) {
|
||||
// If the user has MFA enforced, but did not use it during authentication, we need to throw an error
|
||||
throw new AuthError('MFA not used during authentication');
|
||||
} else {
|
||||
if (allowUnauthenticated) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// In this case we don't want to clear the cookie, to allow for MFA setup
|
||||
res.status(401).json({ status: 'error', message: 'Unauthorized', mfaRequired: true });
|
||||
return;
|
||||
@@ -118,7 +135,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true';
|
||||
const shouldSkipAuth = allowSkipPreviewAuth && isPreviewMode;
|
||||
const shouldSkipAuth = (allowSkipPreviewAuth && isPreviewMode) || allowUnauthenticated;
|
||||
|
||||
if (req.user) next();
|
||||
else if (shouldSkipAuth) next();
|
||||
|
||||
+18
-12
@@ -1,7 +1,7 @@
|
||||
import { inDevelopment, inProduction } from '@n8n/backend-common';
|
||||
import { SecurityConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type { APIRequest } from '@n8n/db';
|
||||
import type { APIRequest, AuthenticatedRequest } from '@n8n/db';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import express from 'express';
|
||||
@@ -269,17 +269,7 @@ export class Server extends AbstractServer {
|
||||
res.sendFile(tzDataFile, { dotfiles: 'allow' }),
|
||||
);
|
||||
|
||||
// ----------------------------------------
|
||||
// Settings
|
||||
// ----------------------------------------
|
||||
|
||||
if (frontendService) {
|
||||
// Returns the current settings for the UI
|
||||
this.app.get(
|
||||
`/${this.restEndpoint}/settings`,
|
||||
ResponseHelper.send(async () => frontendService.getSettings()),
|
||||
);
|
||||
}
|
||||
this.configureSettingsRoute();
|
||||
|
||||
// ----------------------------------------
|
||||
// EventBus Setup
|
||||
@@ -482,6 +472,22 @@ export class Server extends AbstractServer {
|
||||
installGlobalProxyAgent();
|
||||
}
|
||||
|
||||
private configureSettingsRoute() {
|
||||
const { frontendService } = this;
|
||||
const authService = Container.get(AuthService);
|
||||
|
||||
if (frontendService) {
|
||||
// Returns the current settings for the UI
|
||||
this.app.get(
|
||||
`/${this.restEndpoint}/settings`,
|
||||
authService.createAuthMiddleware({ allowSkipMFA: false, allowUnauthenticated: true }),
|
||||
ResponseHelper.send(async (req: AuthenticatedRequest) => {
|
||||
return req.user ? frontendService.getSettings() : frontendService.getPublicSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected setupPushServer(): void {
|
||||
const { restEndpoint, server, app } = this;
|
||||
Container.get(Push).setupPushServer(restEndpoint, server, app);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
|
||||
import type { BinaryDataConfig, InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type { CredentialTypes } from '@/credential-types';
|
||||
import type { CredentialsOverwrites } from '@/credentials-overwrites';
|
||||
@@ -11,50 +12,139 @@ import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import type { MfaService } from '@/mfa/mfa.service';
|
||||
import { CommunityPackagesConfig } from '@/modules/community-packages/community-packages.config';
|
||||
import type { PushConfig } from '@/push/push.config';
|
||||
import { FrontendService } from '@/services/frontend.service';
|
||||
import { FrontendService, type PublicFrontendSettings } from '@/services/frontend.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
describe('FrontendService', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
database: { type: 'sqlite' },
|
||||
endpoints: { rest: 'rest' },
|
||||
diagnostics: { enabled: false },
|
||||
templates: { enabled: false, host: '' },
|
||||
nodes: {},
|
||||
tags: { disabled: false },
|
||||
logging: { level: 'info' },
|
||||
hiringBanner: { enabled: false },
|
||||
versionNotifications: {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
whatsNewEnabled: false,
|
||||
whatsNewEndpoint: '',
|
||||
infoUrl: '',
|
||||
},
|
||||
personalization: { enabled: false },
|
||||
defaultLocale: 'en',
|
||||
auth: { cookie: { secure: false } },
|
||||
generic: { releaseChannel: 'stable', timezone: 'UTC' },
|
||||
publicApi: { path: 'api', swaggerUiDisabled: false },
|
||||
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
|
||||
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
|
||||
hideUsagePage: false,
|
||||
license: { tenantId: 1 },
|
||||
mfa: { enabled: false },
|
||||
deployment: { type: 'default' },
|
||||
workflowHistory: { enabled: false },
|
||||
path: '',
|
||||
sso: {
|
||||
ldap: { loginEnabled: false },
|
||||
saml: { loginEnabled: false },
|
||||
oidc: { loginEnabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const instanceSettings = mock<InstanceSettings>({
|
||||
isDocker: false,
|
||||
instanceId: 'test-instance',
|
||||
isMultiMain: false,
|
||||
hostId: 'test-host',
|
||||
staticCacheDir: '/tmp/test-cache',
|
||||
});
|
||||
|
||||
const logger = mock<Logger>();
|
||||
|
||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
|
||||
addPostProcessor: jest.fn(),
|
||||
types: {
|
||||
credentials: [],
|
||||
nodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const binaryDataConfig = mock<BinaryDataConfig>({
|
||||
mode: 'default',
|
||||
availableModes: ['default'],
|
||||
});
|
||||
|
||||
const credentialTypes = mock<CredentialTypes>({
|
||||
getParentTypes: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const credentialsOverwrites = mock<CredentialsOverwrites>({
|
||||
getAll: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
const license = mock<License>({
|
||||
getUsersLimit: jest.fn().mockReturnValue(100),
|
||||
getPlanName: jest.fn().mockReturnValue('Community'),
|
||||
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
|
||||
isSharingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLdapEnabled: jest.fn().mockReturnValue(false),
|
||||
isSamlEnabled: jest.fn().mockReturnValue(false),
|
||||
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
|
||||
isVariablesEnabled: jest.fn().mockReturnValue(false),
|
||||
isSourceControlLicensed: jest.fn().mockReturnValue(false),
|
||||
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
|
||||
isLicensed: jest.fn().mockReturnValue(false),
|
||||
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
|
||||
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
|
||||
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
|
||||
getVariablesLimit: jest.fn().mockReturnValue(0),
|
||||
getTeamProjectLimit: jest.fn().mockReturnValue(0),
|
||||
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
|
||||
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
|
||||
isAskAiEnabled: jest.fn().mockReturnValue(false),
|
||||
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
|
||||
getAiCredits: jest.fn().mockReturnValue(0),
|
||||
isFoldersEnabled: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const mailer = mock<UserManagementMailer>({
|
||||
isEmailSetUp: false,
|
||||
});
|
||||
|
||||
const urlService = mock<UrlService>({
|
||||
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
});
|
||||
|
||||
const securityConfig = mock<SecurityConfig>({
|
||||
blockFileAccessToN8nFiles: false,
|
||||
});
|
||||
|
||||
const pushConfig = mock<PushConfig>({
|
||||
backend: 'websocket',
|
||||
});
|
||||
|
||||
const licenseState = mock<LicenseState>({
|
||||
isOidcLicensed: jest.fn().mockReturnValue(false),
|
||||
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
|
||||
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
|
||||
});
|
||||
|
||||
const moduleRegistry = mock<ModuleRegistry>({
|
||||
getActiveModules: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const mfaService = mock<MfaService>({
|
||||
isMFAEnforced: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const createMockService = () => {
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
database: { type: 'sqlite' },
|
||||
endpoints: { rest: 'rest' },
|
||||
diagnostics: { enabled: false },
|
||||
templates: { enabled: false, host: '' },
|
||||
nodes: {},
|
||||
tags: { disabled: false },
|
||||
logging: { level: 'info' },
|
||||
hiringBanner: { enabled: false },
|
||||
versionNotifications: {
|
||||
enabled: false,
|
||||
endpoint: '',
|
||||
whatsNewEnabled: false,
|
||||
whatsNewEndpoint: '',
|
||||
infoUrl: '',
|
||||
},
|
||||
personalization: { enabled: false },
|
||||
defaultLocale: 'en',
|
||||
auth: { cookie: { secure: false } },
|
||||
generic: { releaseChannel: 'stable', timezone: 'UTC' },
|
||||
publicApi: { path: 'api', swaggerUiDisabled: false },
|
||||
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
|
||||
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
|
||||
hideUsagePage: false,
|
||||
license: { tenantId: 1 },
|
||||
mfa: { enabled: false },
|
||||
deployment: { type: 'default' },
|
||||
workflowHistory: { enabled: false },
|
||||
path: '',
|
||||
sso: {
|
||||
ldap: { loginEnabled: false },
|
||||
saml: { loginEnabled: false },
|
||||
oidc: { loginEnabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
Container.set(
|
||||
CommunityPackagesConfig,
|
||||
mock<CommunityPackagesConfig>({
|
||||
@@ -62,95 +152,6 @@ describe('FrontendService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const instanceSettings = mock<InstanceSettings>({
|
||||
isDocker: false,
|
||||
instanceId: 'test-instance',
|
||||
isMultiMain: false,
|
||||
hostId: 'test-host',
|
||||
staticCacheDir: '/tmp/test-cache',
|
||||
});
|
||||
|
||||
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
|
||||
addPostProcessor: jest.fn(),
|
||||
types: {
|
||||
credentials: [],
|
||||
nodes: [],
|
||||
},
|
||||
});
|
||||
|
||||
const binaryDataConfig = mock<BinaryDataConfig>({
|
||||
mode: 'default',
|
||||
availableModes: ['default'],
|
||||
});
|
||||
|
||||
const credentialTypes = mock<CredentialTypes>({
|
||||
getParentTypes: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const credentialsOverwrites = mock<CredentialsOverwrites>({
|
||||
getAll: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
const license = mock<License>({
|
||||
getUsersLimit: jest.fn().mockReturnValue(100),
|
||||
getPlanName: jest.fn().mockReturnValue('Community'),
|
||||
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
|
||||
isSharingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
|
||||
isLdapEnabled: jest.fn().mockReturnValue(false),
|
||||
isSamlEnabled: jest.fn().mockReturnValue(false),
|
||||
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
|
||||
isVariablesEnabled: jest.fn().mockReturnValue(false),
|
||||
isSourceControlLicensed: jest.fn().mockReturnValue(false),
|
||||
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
|
||||
isLicensed: jest.fn().mockReturnValue(false),
|
||||
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
|
||||
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
|
||||
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
|
||||
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
|
||||
getVariablesLimit: jest.fn().mockReturnValue(0),
|
||||
getTeamProjectLimit: jest.fn().mockReturnValue(0),
|
||||
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
|
||||
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
|
||||
isAskAiEnabled: jest.fn().mockReturnValue(false),
|
||||
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
|
||||
getAiCredits: jest.fn().mockReturnValue(0),
|
||||
isFoldersEnabled: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
const mailer = mock<UserManagementMailer>({
|
||||
isEmailSetUp: false,
|
||||
});
|
||||
|
||||
const urlService = mock<UrlService>({
|
||||
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
|
||||
});
|
||||
|
||||
const securityConfig = mock<SecurityConfig>({
|
||||
blockFileAccessToN8nFiles: false,
|
||||
});
|
||||
|
||||
const pushConfig = mock<PushConfig>({
|
||||
backend: 'websocket',
|
||||
});
|
||||
|
||||
const licenseState = mock<LicenseState>({
|
||||
isOidcLicensed: jest.fn().mockReturnValue(false),
|
||||
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
|
||||
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
|
||||
});
|
||||
|
||||
const moduleRegistry = mock<ModuleRegistry>({
|
||||
getActiveModules: jest.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const mfaService = mock<MfaService>({
|
||||
isMFAEnforced: jest.fn().mockReturnValue(false),
|
||||
});
|
||||
|
||||
return {
|
||||
service: new FrontendService(
|
||||
globalConfig,
|
||||
@@ -182,6 +183,68 @@ describe('FrontendService', () => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('should return frontend settings', () => {
|
||||
const { service } = createMockService();
|
||||
const settings = service.getSettings();
|
||||
|
||||
expect(settings).toEqual(
|
||||
expect.objectContaining({
|
||||
settingsMode: 'authenticated',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicSettings', () => {
|
||||
it('should return public settings', () => {
|
||||
const expectedPublicSettings: PublicFrontendSettings = {
|
||||
settingsMode: 'public',
|
||||
instanceId: instanceSettings.instanceId,
|
||||
defaultLocale: globalConfig.defaultLocale,
|
||||
versionCli: N8N_VERSION,
|
||||
releaseChannel: globalConfig.generic.releaseChannel,
|
||||
versionNotifications: {
|
||||
enabled: globalConfig.versionNotifications.enabled,
|
||||
endpoint: globalConfig.versionNotifications.endpoint,
|
||||
whatsNewEnabled: globalConfig.versionNotifications.whatsNewEnabled,
|
||||
whatsNewEndpoint: globalConfig.versionNotifications.whatsNewEndpoint,
|
||||
infoUrl: globalConfig.versionNotifications.infoUrl,
|
||||
},
|
||||
userManagement: {
|
||||
quota: 100,
|
||||
smtpSetup: false,
|
||||
showSetupOnFirstLoad: true,
|
||||
authenticationMethod: 'email',
|
||||
},
|
||||
sso: {
|
||||
saml: { loginEnabled: false, loginLabel: '' },
|
||||
ldap: { loginEnabled: false, loginLabel: '' },
|
||||
oidc: {
|
||||
loginEnabled: false,
|
||||
loginUrl: 'http://localhost:5678/rest/sso/oidc/login',
|
||||
callbackUrl: 'http://localhost:5678/rest/sso/oidc/callback',
|
||||
},
|
||||
},
|
||||
mfa: { enabled: false, enforced: false },
|
||||
authCookie: { secure: false },
|
||||
oauthCallbackUrls: {
|
||||
oauth1: 'http://localhost:5678/rest/oauth1-credential/callback',
|
||||
oauth2: 'http://localhost:5678/rest/oauth2-credential/callback',
|
||||
},
|
||||
banners: { dismissed: [] },
|
||||
previewMode: false,
|
||||
telemetry: { enabled: false },
|
||||
enterprise: { saml: false, ldap: false, oidc: false, showNonProdBanner: false },
|
||||
};
|
||||
|
||||
const { service } = createMockService();
|
||||
const settings = service.getPublicSettings();
|
||||
|
||||
expect(settings).toEqual(expectedPublicSettings);
|
||||
});
|
||||
});
|
||||
|
||||
describe('envFeatureFlags functionality', () => {
|
||||
describe('collectEnvFeatureFlags', () => {
|
||||
it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
import type {
|
||||
FrontendSettings,
|
||||
IEnterpriseSettings,
|
||||
ITelemetrySettings,
|
||||
N8nEnvFeatFlags,
|
||||
} from '@n8n/api-types';
|
||||
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||
@@ -32,6 +37,31 @@ import {
|
||||
|
||||
import { UrlService } from './url.service';
|
||||
|
||||
export type PublicEnterpriseSettings = Pick<
|
||||
IEnterpriseSettings,
|
||||
'saml' | 'ldap' | 'oidc' | 'showNonProdBanner'
|
||||
>;
|
||||
|
||||
export type PublicFrontendSettings = Pick<
|
||||
FrontendSettings,
|
||||
| 'settingsMode'
|
||||
| 'instanceId'
|
||||
| 'defaultLocale'
|
||||
| 'versionCli'
|
||||
| 'releaseChannel'
|
||||
| 'versionNotifications'
|
||||
| 'userManagement'
|
||||
| 'sso'
|
||||
| 'mfa'
|
||||
| 'authCookie'
|
||||
| 'oauthCallbackUrls'
|
||||
| 'banners'
|
||||
| 'previewMode'
|
||||
| 'telemetry'
|
||||
> & {
|
||||
enterprise: PublicEnterpriseSettings;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class FrontendService {
|
||||
settings: FrontendSettings;
|
||||
@@ -105,6 +135,7 @@ export class FrontendService {
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
settingsMode: 'authenticated',
|
||||
inE2ETests,
|
||||
isDocker: this.instanceSettings.isDocker,
|
||||
databaseType: this.globalConfig.database.type,
|
||||
@@ -458,6 +489,48 @@ export class FrontendService {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only add settings that are absolutely necessary for non-authenticated pages
|
||||
* @returns Public settings for unauthenticated users
|
||||
*/
|
||||
getPublicSettings(): PublicFrontendSettings {
|
||||
// Get full settings to ensure all required properties are initialized
|
||||
const {
|
||||
instanceId,
|
||||
defaultLocale,
|
||||
versionCli,
|
||||
releaseChannel,
|
||||
versionNotifications,
|
||||
userManagement,
|
||||
sso,
|
||||
mfa,
|
||||
authCookie,
|
||||
oauthCallbackUrls,
|
||||
banners,
|
||||
previewMode,
|
||||
telemetry,
|
||||
enterprise: { saml, ldap, oidc, showNonProdBanner },
|
||||
} = this.getSettings();
|
||||
|
||||
return {
|
||||
settingsMode: 'public',
|
||||
instanceId,
|
||||
defaultLocale,
|
||||
versionCli,
|
||||
releaseChannel,
|
||||
versionNotifications,
|
||||
userManagement,
|
||||
sso,
|
||||
mfa,
|
||||
authCookie,
|
||||
oauthCallbackUrls,
|
||||
banners,
|
||||
previewMode,
|
||||
telemetry,
|
||||
enterprise: { saml, ldap, oidc, showNonProdBanner },
|
||||
};
|
||||
}
|
||||
|
||||
getModuleSettings() {
|
||||
return Object.fromEntries(this.moduleRegistry.settings);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,20 @@ import type { CurrentUserResponse } from '@n8n/rest-api-client/api/users';
|
||||
import { useUsersStore } from './users.store';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
const { loginCurrentUser, inviteUsers } = vi.hoisted(() => {
|
||||
const { loginCurrentUser, inviteUsers, login, logout } = vi.hoisted(() => {
|
||||
return {
|
||||
loginCurrentUser: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
inviteUsers: vi.fn(),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@n8n/rest-api-client/api/users', () => ({
|
||||
loginCurrentUser,
|
||||
login,
|
||||
logout,
|
||||
}));
|
||||
|
||||
vi.mock('./invitation.api', () => ({
|
||||
@@ -149,4 +153,100 @@ describe('users.store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loggingHooks', () => {
|
||||
it('should run all registered loginHooks', async () => {
|
||||
const usersStore = useUsersStore();
|
||||
loginCurrentUser.mockResolvedValueOnce(mockUser);
|
||||
|
||||
const hook1 = vi.fn(async () => {});
|
||||
const hook2 = vi.fn(async () => {});
|
||||
const hook3 = vi.fn(async () => {});
|
||||
|
||||
usersStore.registerLoginHook(hook1);
|
||||
usersStore.registerLoginHook(hook2);
|
||||
usersStore.registerLoginHook(hook3);
|
||||
|
||||
await usersStore.loginWithCookie();
|
||||
|
||||
expect(hook1).toHaveBeenCalled();
|
||||
expect(hook2).toHaveBeenCalled();
|
||||
expect(hook3).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail silently if a login hook fails', async () => {
|
||||
const usersStore = useUsersStore();
|
||||
login.mockResolvedValueOnce(mockUser);
|
||||
|
||||
const errorHook = vi.fn(() => {
|
||||
throw new Error('Hook failed');
|
||||
});
|
||||
const errorAsyncHook = vi.fn(async () => {
|
||||
throw new Error('Hook failed');
|
||||
});
|
||||
const successAsyncHook = vi.fn(async () => {});
|
||||
const successHook = vi.fn();
|
||||
|
||||
usersStore.registerLoginHook(errorHook);
|
||||
usersStore.registerLoginHook(errorAsyncHook);
|
||||
usersStore.registerLoginHook(successAsyncHook);
|
||||
usersStore.registerLoginHook(successHook);
|
||||
|
||||
await usersStore.loginWithCreds({
|
||||
emailOrLdapLoginId: 'test@n8n.io',
|
||||
password: 'test-password',
|
||||
});
|
||||
|
||||
expect(errorHook).toHaveBeenCalled();
|
||||
expect(errorAsyncHook).toHaveBeenCalled();
|
||||
expect(successAsyncHook).toHaveBeenCalled();
|
||||
expect(successHook).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutHooks', () => {
|
||||
it('should run all registered logoutHooks', async () => {
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const hook1 = vi.fn();
|
||||
const hook2 = vi.fn(async () => {});
|
||||
const hook3 = vi.fn(async () => {});
|
||||
|
||||
usersStore.registerLogoutHook(hook1);
|
||||
usersStore.registerLogoutHook(hook2);
|
||||
usersStore.registerLogoutHook(hook3);
|
||||
|
||||
await usersStore.logout();
|
||||
|
||||
expect(hook1).toHaveBeenCalled();
|
||||
expect(hook2).toHaveBeenCalled();
|
||||
expect(hook3).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail silently if a logout hook fails', async () => {
|
||||
const usersStore = useUsersStore();
|
||||
logout.mockResolvedValueOnce(mockUser);
|
||||
|
||||
const errorHook = vi.fn(() => {
|
||||
throw new Error('Hook failed');
|
||||
});
|
||||
const errorAsyncHook = vi.fn(async () => {
|
||||
throw new Error('Hook failed');
|
||||
});
|
||||
const successAsyncHook = vi.fn(async () => {});
|
||||
const successHook = vi.fn();
|
||||
|
||||
usersStore.registerLogoutHook(errorHook);
|
||||
usersStore.registerLogoutHook(errorAsyncHook);
|
||||
usersStore.registerLogoutHook(successAsyncHook);
|
||||
usersStore.registerLogoutHook(successHook);
|
||||
|
||||
await usersStore.logout();
|
||||
|
||||
expect(errorHook).toHaveBeenCalled();
|
||||
expect(errorAsyncHook).toHaveBeenCalled();
|
||||
expect(successAsyncHook).toHaveBeenCalled();
|
||||
expect(successHook).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,8 +38,8 @@ const _isDefaultUser = (user: IUserResponse | null) =>
|
||||
_isInstanceOwner(user) && _isPendingUser(user);
|
||||
const _isAdmin = (user: IUserResponse | null) => user?.role === ROLE.Admin;
|
||||
|
||||
export type LoginHook = (user: CurrentUserResponse) => void;
|
||||
type LogoutHook = () => void;
|
||||
export type LoginHook = (user: CurrentUserResponse) => void | Promise<void>;
|
||||
type LogoutHook = () => void | Promise<void>;
|
||||
|
||||
export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
const initialized = ref(false);
|
||||
@@ -147,13 +147,13 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
});
|
||||
};
|
||||
|
||||
const setCurrentUser = (user: CurrentUserResponse) => {
|
||||
const setCurrentUser = async (user: CurrentUserResponse) => {
|
||||
addUsers([user]);
|
||||
currentUserId.value = user.id;
|
||||
|
||||
for (const hook of loginHooks.value) {
|
||||
try {
|
||||
hook(user);
|
||||
await hook(user);
|
||||
} catch (error) {
|
||||
console.error('Error executing login hook:', error);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUser(user);
|
||||
await setCurrentUser(user);
|
||||
};
|
||||
|
||||
const initialize = async (options: { quota?: number } = {}) => {
|
||||
@@ -213,7 +213,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUser(user);
|
||||
await setCurrentUser(user);
|
||||
};
|
||||
|
||||
const registerLoginHook = (hook: LoginHook) => {
|
||||
@@ -231,7 +231,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
|
||||
for (const hook of logoutHooks.value) {
|
||||
try {
|
||||
hook();
|
||||
await hook();
|
||||
} catch (error) {
|
||||
console.error('Error executing logout hook:', error);
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
}) => {
|
||||
const user = await usersApi.setupOwner(rootStore.restApiContext, params);
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
await setCurrentUser(user);
|
||||
settingsStore.stopShowingSetupPage();
|
||||
}
|
||||
};
|
||||
@@ -266,7 +266,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
}) => {
|
||||
const user = await invitationsApi.acceptInvitation(rootStore.restApiContext, params);
|
||||
if (user) {
|
||||
setCurrentUser(user);
|
||||
await setCurrentUser(user);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -120,9 +120,9 @@ describe('Init', () => {
|
||||
|
||||
it('should correctly identify the user for telemetry', async () => {
|
||||
const telemetryIdentifySpy = vi.spyOn(telemetry, 'identify');
|
||||
usersStore.registerLoginHook.mockImplementation((hook) =>
|
||||
hook(mock<CurrentUserResponse>({ id: 'userId' })),
|
||||
);
|
||||
usersStore.registerLoginHook.mockImplementation(async (hook) => {
|
||||
await hook(mock<CurrentUserResponse>({ id: 'userId' }));
|
||||
});
|
||||
rootStore.instanceId = 'testInstanceId';
|
||||
rootStore.versionCli = '1.102.0';
|
||||
|
||||
|
||||
@@ -226,7 +226,9 @@ function registerAuthenticationHooks() {
|
||||
const RBACStore = useRBACStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
usersStore.registerLoginHook((user) => {
|
||||
usersStore.registerLoginHook(async (user) => {
|
||||
await settingsStore.getSettings();
|
||||
|
||||
RBACStore.setGlobalScopes(user.globalScopes ?? []);
|
||||
telemetry.identify(rootStore.instanceId, user.id, rootStore.versionCli);
|
||||
postHogStore.init(user.featureFlags);
|
||||
|
||||
@@ -3,8 +3,32 @@ import { createPinia, setActivePinia } from 'pinia';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { useSettingsStore } from './settings.store';
|
||||
|
||||
const { getSettings } = vi.hoisted(() => ({
|
||||
const mockRootStore = {
|
||||
restApiContext: {},
|
||||
setUrlBaseWebhook: vi.fn(),
|
||||
setUrlBaseEditor: vi.fn(),
|
||||
setEndpointForm: vi.fn(),
|
||||
setEndpointFormTest: vi.fn(),
|
||||
setEndpointFormWaiting: vi.fn(),
|
||||
setEndpointWebhook: vi.fn(),
|
||||
setEndpointWebhookTest: vi.fn(),
|
||||
setEndpointWebhookWaiting: vi.fn(),
|
||||
setEndpointMcp: vi.fn(),
|
||||
setEndpointMcpTest: vi.fn(),
|
||||
setTimezone: vi.fn(),
|
||||
setExecutionTimeout: vi.fn(),
|
||||
setMaxExecutionTimeout: vi.fn(),
|
||||
setInstanceId: vi.fn(),
|
||||
setOauthCallbackUrls: vi.fn(),
|
||||
setN8nMetadata: vi.fn(),
|
||||
setDefaultLocale: vi.fn(),
|
||||
setBinaryDataMode: vi.fn(),
|
||||
setVersionCli: vi.fn(),
|
||||
};
|
||||
|
||||
const { getSettings, useRootStore } = vi.hoisted(() => ({
|
||||
getSettings: vi.fn(),
|
||||
useRootStore: vi.fn(() => mockRootStore),
|
||||
}));
|
||||
|
||||
const { sessionStarted } = vi.hoisted(() => ({
|
||||
@@ -20,28 +44,7 @@ vi.mock('@n8n/rest-api-client/api/events', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn(() => ({
|
||||
restApiContext: {},
|
||||
setUrlBaseWebhook: vi.fn(),
|
||||
setUrlBaseEditor: vi.fn(),
|
||||
setEndpointForm: vi.fn(),
|
||||
setEndpointFormTest: vi.fn(),
|
||||
setEndpointFormWaiting: vi.fn(),
|
||||
setEndpointWebhook: vi.fn(),
|
||||
setEndpointWebhookTest: vi.fn(),
|
||||
setEndpointWebhookWaiting: vi.fn(),
|
||||
setEndpointMcp: vi.fn(),
|
||||
setEndpointMcpTest: vi.fn(),
|
||||
setTimezone: vi.fn(),
|
||||
setExecutionTimeout: vi.fn(),
|
||||
setMaxExecutionTimeout: vi.fn(),
|
||||
setInstanceId: vi.fn(),
|
||||
setOauthCallbackUrls: vi.fn(),
|
||||
setN8nMetadata: vi.fn(),
|
||||
setDefaultLocale: vi.fn(),
|
||||
setBinaryDataMode: vi.fn(),
|
||||
setVersionCli: vi.fn(),
|
||||
})),
|
||||
useRootStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/versions.store', () => ({
|
||||
@@ -61,48 +64,149 @@ vi.mock('@vueuse/core', async () => {
|
||||
});
|
||||
|
||||
const mockSettings = mock<FrontendSettings>({
|
||||
releaseChannel: 'stable',
|
||||
authCookie: { secure: true },
|
||||
oauthCallbackUrls: {
|
||||
oauth1: 'https://oauth1.example.com',
|
||||
oauth2: 'https://oauth2.example.com',
|
||||
},
|
||||
defaultLocale: 'en',
|
||||
instanceId: '1234567890',
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('settings.store', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('should fetch settings and call sessionStarted if telemetry is enabled', async () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
describe('telemetry', () => {
|
||||
it('should fetch settings and call sessionStarted if telemetry is enabled', async () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://telemetry.example.com',
|
||||
key: 'telemetry-key',
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://telemetry.example.com',
|
||||
key: 'telemetry-key',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await settingsStore.getSettings();
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
expect(sessionStarted).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await settingsStore.getSettings();
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
expect(sessionStarted).toHaveBeenCalled();
|
||||
it('should fetch settings and skip calling sessionStarted if telemetry is disabled', async () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
await settingsStore.getSettings();
|
||||
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
expect(sessionStarted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch settings and skip calling sessionStarted if telemetry is disabled', async () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
describe('settingsMode', () => {
|
||||
it('should only set public settings if settingsMode is "public"', async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
settingsMode: 'public',
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://telemetry.example.com',
|
||||
key: 'telemetry-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
telemetry: {
|
||||
enabled: false,
|
||||
},
|
||||
await settingsStore.getSettings();
|
||||
|
||||
// ensure that settings store is also initialized
|
||||
expect(settingsStore.settings.releaseChannel).toEqual(mockSettings.releaseChannel);
|
||||
|
||||
// root store
|
||||
expect(mockRootStore.setOauthCallbackUrls).toHaveBeenCalledWith(
|
||||
mockSettings.oauthCallbackUrls,
|
||||
);
|
||||
expect(mockRootStore.setDefaultLocale).toHaveBeenCalledWith(mockSettings.defaultLocale);
|
||||
expect(mockRootStore.setInstanceId).toHaveBeenCalledWith(mockSettings.instanceId);
|
||||
|
||||
// non-minimal settings are not set on root store
|
||||
expect(mockRootStore.setUrlBaseWebhook).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setUrlBaseEditor).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointForm).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointFormTest).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointFormWaiting).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhook).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhookTest).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhookWaiting).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointMcp).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointMcpTest).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setTimezone).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setExecutionTimeout).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setMaxExecutionTimeout).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setN8nMetadata).not.toHaveBeenCalled();
|
||||
expect(mockRootStore.setBinaryDataMode).not.toHaveBeenCalled();
|
||||
|
||||
// side effects
|
||||
expect(sessionStarted).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await settingsStore.getSettings();
|
||||
expect(getSettings).toHaveBeenCalled();
|
||||
expect(sessionStarted).not.toHaveBeenCalled();
|
||||
it('should store full settings if settingsMode is not "minimal"', async () => {
|
||||
getSettings.mockResolvedValueOnce({
|
||||
...mockSettings,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
config: {
|
||||
url: 'https://telemetry.example.com',
|
||||
key: 'telemetry-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
await settingsStore.getSettings();
|
||||
|
||||
// root store
|
||||
expect(mockRootStore.setUrlBaseWebhook).toHaveBeenCalled();
|
||||
expect(mockRootStore.setUrlBaseEditor).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointForm).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointFormTest).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointFormWaiting).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhook).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhookTest).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointWebhookWaiting).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointMcp).toHaveBeenCalled();
|
||||
expect(mockRootStore.setEndpointMcpTest).toHaveBeenCalled();
|
||||
expect(mockRootStore.setTimezone).toHaveBeenCalled();
|
||||
expect(mockRootStore.setExecutionTimeout).toHaveBeenCalled();
|
||||
expect(mockRootStore.setMaxExecutionTimeout).toHaveBeenCalled();
|
||||
expect(mockRootStore.setInstanceId).toHaveBeenCalled();
|
||||
expect(mockRootStore.setOauthCallbackUrls).toHaveBeenCalled();
|
||||
expect(mockRootStore.setN8nMetadata).toHaveBeenCalled();
|
||||
expect(mockRootStore.setDefaultLocale).toHaveBeenCalled();
|
||||
expect(mockRootStore.setBinaryDataMode).toHaveBeenCalled();
|
||||
|
||||
// side effects
|
||||
expect(sessionStarted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,13 +151,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
|
||||
const isHiringBannerEnabled = computed(() => settings.value.hiringBannerEnabled);
|
||||
|
||||
const isTemplatesEnabled = computed(() =>
|
||||
Boolean(settings.value.templates && settings.value.templates.enabled),
|
||||
);
|
||||
const isTemplatesEnabled = computed(() => Boolean(settings.value.templates?.enabled));
|
||||
|
||||
const isTemplatesEndpointReachable = computed(() => templatesEndpointHealthy.value);
|
||||
|
||||
const templatesHost = computed(() => settings.value.templates.host);
|
||||
const templatesHost = computed(() => settings.value.templates?.host ?? '');
|
||||
|
||||
const pushBackend = computed(() => settings.value.pushBackend);
|
||||
|
||||
@@ -192,7 +190,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
userManagement.value.showSetupOnFirstLoad =
|
||||
!!settings.value.userManagement.showSetupOnFirstLoad;
|
||||
}
|
||||
api.value = settings.value.publicApi;
|
||||
|
||||
if (settings.value.publicApi) {
|
||||
api.value = settings.value.publicApi;
|
||||
}
|
||||
|
||||
mfa.value.enabled = settings.value.mfa?.enabled;
|
||||
folders.value.enabled = settings.value.folders?.enabled;
|
||||
|
||||
@@ -232,9 +234,32 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
saveDataProgressExecution.value = newValue;
|
||||
};
|
||||
|
||||
const setPublicSettings = (fetchedSettings: FrontendSettings) => {
|
||||
const rootStore = useRootStore();
|
||||
setSettings(fetchedSettings);
|
||||
|
||||
isMFAEnforced.value = settings.value.mfa?.enforced ?? false;
|
||||
|
||||
rootStore.setOauthCallbackUrls(fetchedSettings.oauthCallbackUrls);
|
||||
rootStore.setDefaultLocale(fetchedSettings.defaultLocale);
|
||||
rootStore.setInstanceId(fetchedSettings.instanceId);
|
||||
|
||||
if (fetchedSettings.telemetry.enabled) {
|
||||
void eventsApi.sessionStarted(rootStore.restApiContext);
|
||||
}
|
||||
};
|
||||
|
||||
const getSettings = async () => {
|
||||
const rootStore = useRootStore();
|
||||
const fetchedSettings = await settingsApi.getSettings(rootStore.restApiContext);
|
||||
|
||||
if (fetchedSettings.settingsMode === 'public') {
|
||||
// public settings mode is typically used for unauthenticated users
|
||||
// when public settings are returned only critical setup is needed
|
||||
setPublicSettings(fetchedSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings(fetchedSettings);
|
||||
settings.value.communityNodesEnabled = fetchedSettings.communityNodesEnabled;
|
||||
settings.value.unverifiedCommunityNodesEnabled =
|
||||
|
||||
Reference in New Issue
Block a user