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:
@ -11,6 +11,7 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
|||||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||||
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
|
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
|
||||||
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
|
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()
|
@Resolver()
|
||||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||||
@ -40,7 +41,7 @@ export class AdminPanelResolver {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
await this.adminService.updateWorkspaceFeatureFlags(
|
await this.adminService.updateWorkspaceFeatureFlags(
|
||||||
updateFlagInput.workspaceId,
|
updateFlagInput.workspaceId,
|
||||||
updateFlagInput.featureFlag,
|
FeatureFlagKey[updateFlagInput.featureFlag],
|
||||||
updateFlagInput.value,
|
updateFlagInput.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.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 { 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()
|
@Injectable()
|
||||||
export class AdminPanelService {
|
export class AdminPanelService {
|
||||||
@ -44,14 +45,9 @@ export class AdminPanelService {
|
|||||||
|
|
||||||
userValidator.assertIsDefinedOrThrow(
|
userValidator.assertIsDefinedOrThrow(
|
||||||
user,
|
user,
|
||||||
new AuthException('User not found', AuthExceptionCode.INVALID_INPUT),
|
|
||||||
);
|
|
||||||
|
|
||||||
workspaceValidator.assertIsDefinedOrThrow(
|
|
||||||
user.workspaces[0].workspace,
|
|
||||||
new AuthException(
|
new AuthException(
|
||||||
'Impersonation not allowed',
|
'User not found or impersonation not enable on workspace',
|
||||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
AuthExceptionCode.INVALID_INPUT,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -122,9 +118,17 @@ export class AdminPanelService {
|
|||||||
|
|
||||||
async updateWorkspaceFeatureFlags(
|
async updateWorkspaceFeatureFlags(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
featureFlag: FeatureFlagKey,
|
featureFlag: string,
|
||||||
value: boolean,
|
value: boolean,
|
||||||
) {
|
) {
|
||||||
|
featureFlagValidator.assertIsFeatureFlagKey(
|
||||||
|
featureFlag,
|
||||||
|
new AuthException(
|
||||||
|
'Invalid feature flag key',
|
||||||
|
AuthExceptionCode.INVALID_INPUT,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const workspace = await this.workspaceRepository.findOne({
|
const workspace = await this.workspaceRepository.findOne({
|
||||||
where: { id: workspaceId },
|
where: { id: workspaceId },
|
||||||
relations: ['featureFlags'],
|
relations: ['featureFlags'],
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -26,7 +26,7 @@ export class FeatureFlagEntity {
|
|||||||
|
|
||||||
@Field(() => FeatureFlagKey)
|
@Field(() => FeatureFlagKey)
|
||||||
@Column({ nullable: false, type: 'text' })
|
@Column({ nullable: false, type: 'text' })
|
||||||
key: FeatureFlagKey;
|
key: `${FeatureFlagKey}`;
|
||||||
|
|
||||||
@Field()
|
@Field()
|
||||||
@Column({ nullable: false, type: 'uuid' })
|
@Column({ nullable: false, type: 'uuid' })
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user