fix(admin-panel): resolve feature flag key mismatch (#9530)

Update feature flag handling by mapping input keys to enum values. This
ensures compatibility and prevents potential runtime errors when
updating workspace feature flags.
This commit is contained in:
Antoine Moreaux
2025-01-10 12:30:42 +01:00
committed by GitHub
parent c716a30d92
commit 75bf9e3c69
7 changed files with 324 additions and 10 deletions

View File

@ -11,6 +11,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
@ -40,7 +41,7 @@ export class AdminPanelResolver {
): Promise<boolean> {
await this.adminService.updateWorkspaceFeatureFlags(
updateFlagInput.workspaceId,
updateFlagInput.featureFlag,
FeatureFlagKey[updateFlagInput.featureFlag],
updateFlagInput.value,
);

View File

@ -15,6 +15,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
@Injectable()
export class AdminPanelService {
@ -44,14 +45,9 @@ export class AdminPanelService {
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
);
workspaceValidator.assertIsDefinedOrThrow(
user.workspaces[0].workspace,
new AuthException(
'Impersonation not allowed',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
'User not found or impersonation not enable on workspace',
AuthExceptionCode.INVALID_INPUT,
),
);
@ -122,9 +118,17 @@ export class AdminPanelService {
async updateWorkspaceFeatureFlags(
workspaceId: string,
featureFlag: FeatureFlagKey,
featureFlag: string,
value: boolean,
) {
featureFlagValidator.assertIsFeatureFlagKey(
featureFlag,
new AuthException(
'Invalid feature flag key',
AuthExceptionCode.INVALID_INPUT,
),
);
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
relations: ['featureFlags'],

View File

@ -0,0 +1,214 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { expect, jest } from '@jest/globals';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
const UserFindOneMock = jest.fn();
const WorkspaceFindOneMock = jest.fn();
const FeatureFlagUpdateMock = jest.fn();
const FeatureFlagSaveMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
jest.mock(
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
() => {
return {
FeatureFlagKey: {
IsFlagEnabled: 'IS_FLAG_ENABLED',
},
};
},
);
describe('AdminPanelService', () => {
let service: AdminPanelService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminPanelService,
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {
findOne: WorkspaceFindOneMock,
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {
findOne: UserFindOneMock,
},
},
{
provide: getRepositoryToken(FeatureFlagEntity, 'core'),
useValue: {
update: FeatureFlagUpdateMock,
save: FeatureFlagSaveMock,
},
},
{
provide: LoginTokenService,
useValue: {
generateLoginToken: LoginTokenServiceGenerateLoginTokenMock,
},
},
],
}).compile();
service = module.get<AdminPanelService>(AdminPanelService);
});
it('should be defined', async () => {
expect(service).toBeDefined();
});
it('should update an existing feature flag if it exists', async () => {
const workspaceId = 'workspace-id';
const featureFlag = 'IsFlagEnabled';
const value = true;
const existingFlag = { id: 'flag-id', key: featureFlag, value: false };
WorkspaceFindOneMock.mockReturnValueOnce({
id: workspaceId,
featureFlags: [existingFlag],
});
await service.updateWorkspaceFeatureFlags(workspaceId, featureFlag, value);
expect(FeatureFlagUpdateMock).toHaveBeenCalledWith(existingFlag.id, {
value,
});
expect(FeatureFlagSaveMock).not.toHaveBeenCalled();
});
it('should create a new feature flag if it does not exist', async () => {
const workspaceId = 'workspace-id';
const featureFlag = 'IsFlagEnabled';
const value = true;
WorkspaceFindOneMock.mockReturnValueOnce({
id: workspaceId,
featureFlags: [],
});
await service.updateWorkspaceFeatureFlags(workspaceId, featureFlag, value);
expect(FeatureFlagSaveMock).toHaveBeenCalledWith({
key: featureFlag,
value,
workspaceId,
});
expect(FeatureFlagUpdateMock).not.toHaveBeenCalled();
});
it('should throw an exception if the workspace is not found', async () => {
const workspaceId = 'non-existent-workspace';
const featureFlag = 'IsFlagEnabled';
const value = true;
WorkspaceFindOneMock.mockReturnValueOnce(null);
await expect(
service.updateWorkspaceFeatureFlags(workspaceId, featureFlag, value),
).rejects.toThrowError(
new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
);
});
it('should throw an exception if the flag is not found', async () => {
const workspaceId = 'non-existent-workspace';
const featureFlag = 'IsUnknownFlagEnabled';
const value = true;
WorkspaceFindOneMock.mockReturnValueOnce(null);
await expect(
service.updateWorkspaceFeatureFlags(workspaceId, featureFlag, value),
).rejects.toThrowError(
new AuthException(
'Invalid feature flag key',
AuthExceptionCode.INVALID_INPUT,
),
);
});
it('should impersonate a user and return workspace and loginToken on success', async () => {
const mockUser = {
id: 'user-id',
email: 'user@example.com',
workspaces: [
{
workspace: {
id: 'workspace-id',
allowImpersonation: true,
subdomain: 'example-subdomain',
},
},
],
};
UserFindOneMock.mockReturnValueOnce(mockUser);
LoginTokenServiceGenerateLoginTokenMock.mockReturnValueOnce({
token: 'mock-login-token',
expiresAt: new Date(),
});
const result = await service.impersonate('user-id', 'workspace-id');
expect(UserFindOneMock).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'user-id',
workspaces: {
workspaceId: 'workspace-id',
workspace: { allowImpersonation: true },
},
}),
relations: ['workspaces', 'workspaces.workspace'],
}),
);
expect(LoginTokenServiceGenerateLoginTokenMock).toHaveBeenCalledWith(
'user@example.com',
'workspace-id',
);
expect(result).toEqual(
expect.objectContaining({
workspace: {
id: 'workspace-id',
subdomain: 'example-subdomain',
},
loginToken: expect.objectContaining({
token: 'mock-login-token',
expiresAt: expect.any(Date),
}),
}),
);
});
it('should throw an error when user is not found', async () => {
UserFindOneMock.mockReturnValueOnce(null);
await expect(
service.impersonate('invalid-user-id', 'workspace-id'),
).rejects.toThrow(
new AuthException(
'User not found or impersonation not enable on workspace',
AuthExceptionCode.INVALID_INPUT,
),
);
expect(UserFindOneMock).toHaveBeenCalled();
});
});

View File

@ -26,7 +26,7 @@ export class FeatureFlagEntity {
@Field(() => FeatureFlagKey)
@Column({ nullable: false, type: 'text' })
key: FeatureFlagKey;
key: `${FeatureFlagKey}`;
@Field()
@Column({ nullable: false, type: 'uuid' })

View File

@ -0,0 +1,24 @@
import { CustomException } from 'src/utils/custom-exception';
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
describe('featureFlagValidator', () => {
describe('assertIsFeatureFlagKey', () => {
it('should not throw error if featureFlagKey is valid', () => {
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(
'IsWorkflowEnabled',
new CustomException('Error', 'Error'),
),
).not.toThrow();
});
it('should throw error if featureFlagKey is invalid', () => {
const invalidKey = 'InvalidKey';
const exception = new CustomException('Error', 'Error');
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(invalidKey, exception),
).toThrow(exception);
});
});
});

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { isDefined } from 'src/utils/is-defined';
const assertIsFeatureFlagKey = (
featureFlagKey: string,
exceptionToThrow: CustomException,
): asserts featureFlagKey is FeatureFlagKey => {
if (isDefined(FeatureFlagKey[featureFlagKey])) return;
throw exceptionToThrow;
};
export const featureFlagValidator: {
assertIsFeatureFlagKey: typeof assertIsFeatureFlagKey;
} = {
assertIsFeatureFlagKey: assertIsFeatureFlagKey,
};

View File

@ -0,0 +1,54 @@
import { ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { expect, jest } from '@jest/globals';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
describe('ImpersonateGuard', () => {
const guard = new ImpersonateGuard();
it('should return true if user can impersonate', async () => {
const mockContext = {
getContext: jest.fn(() => ({
req: {
user: {
canImpersonate: true,
},
},
})),
};
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
const mockExecutionContext = {} as ExecutionContext;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
});
it('should return false if user cannot impersonate', async () => {
const mockContext = {
getContext: jest.fn(() => ({
req: {
user: {
canImpersonate: false,
},
},
})),
};
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
const mockExecutionContext = {} as ExecutionContext;
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(false);
});
});