Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25,054 changes: 0 additions & 25,054 deletions package-lock.json

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions src/common/guards/feature-flag.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { FeatureFlagGuard } from './feature-flag.guard';
import { NotFoundException } from '@nestjs/common';

describe('FeatureFlagGuard', () => {
let GuardClass: any;
let guard: any;
let flagsServiceMock: any;

beforeEach(() => {
flagsServiceMock = {
isEnabled: jest.fn(),
};
GuardClass = FeatureFlagGuard('dao_voting');
guard = new GuardClass(flagsServiceMock);
});

it('should return true if flag is enabled', async () => {
flagsServiceMock.isEnabled.mockResolvedValue(true);
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({ user: { userId: 'user1' } }),
}),
};

const result = await guard.canActivate(mockContext as any);
expect(result).toBe(true);
expect(flagsServiceMock.isEnabled).toHaveBeenCalledWith(
'dao_voting',
'user1',
);
});

it('should return true if flag is enabled and user is undefined', async () => {
flagsServiceMock.isEnabled.mockResolvedValue(true);
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({}), // No user
}),
};

const result = await guard.canActivate(mockContext as any);
expect(result).toBe(true);
expect(flagsServiceMock.isEnabled).toHaveBeenCalledWith(
'dao_voting',
undefined,
);
});

it('should throw NotFoundException if flag is disabled', async () => {
flagsServiceMock.isEnabled.mockResolvedValue(false);
const mockContext = {
switchToHttp: () => ({
getRequest: () => ({ user: { userId: 'user1' } }),
}),
};

await expect(guard.canActivate(mockContext as any)).rejects.toThrow(
NotFoundException,
);
});
});
31 changes: 31 additions & 0 deletions src/common/guards/feature-flag.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
CanActivate,
ExecutionContext,
Injectable,
mixin,
NotFoundException,
} from '@nestjs/common';
import { FlagsService } from '../../modules/flags/flags.service';

export function FeatureFlagGuard(flagKey: string) {
@Injectable()
class MixinFeatureFlagGuard implements CanActivate {
constructor(private readonly flagsService: FlagsService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
// Assume JwtAuthGuard has run first to populate request.user
const userId = request.user?.userId;

const isEnabled = await this.flagsService.isEnabled(flagKey, userId);

if (!isEnabled) {
throw new NotFoundException(); // 404 not 403 to prevent feature discovery
}

return true;
}
}

return mixin(MixinFeatureFlagGuard);
}
13 changes: 7 additions & 6 deletions src/dao/controllers/proposal.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ProposalService } from '../services/proposal.service';
import { CreateProposalDto } from '../dto/create-proposal.dto';
import { CastVoteDto } from '../dto/cast-vote.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { FeatureFlagGuard } from '../../common/guards/feature-flag.guard';
import { RolesGuard } from '../../auth/guards/roles.guard';
import { Roles } from '../../auth/decorators/roles.decorator';
import { UserRole } from '../../users/user.entity';
Expand All @@ -37,7 +38,7 @@ export class ProposalController {
) {}

@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard('dao_voting'))
@Roles(UserRole.ADMIN)
@HttpCode(201)
@ApiOperation({
Expand Down Expand Up @@ -80,7 +81,7 @@ export class ProposalController {
}

@Post(':id/vote')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting'))
@HttpCode(201)
@ApiOperation({
summary: 'Cast a vote on a proposal',
Expand Down Expand Up @@ -130,7 +131,7 @@ export class ProposalController {
}

@Get(':id')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting'))
@ApiOperation({
summary: 'Get proposal detail with current vote counts',
})
Expand Down Expand Up @@ -173,7 +174,7 @@ export class ProposalController {
}

@Get(':id/results')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting'))
@ApiOperation({
summary: 'Get voting results for a proposal',
})
Expand Down Expand Up @@ -219,7 +220,7 @@ export class ProposalController {
}

@Get()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, FeatureFlagGuard('dao_voting'))
@ApiOperation({
summary: 'List all proposals with pagination',
})
Expand Down Expand Up @@ -276,7 +277,7 @@ export class ProposalController {
}

@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard('dao_voting'))
@Roles(UserRole.ADMIN)
@ApiOperation({
summary: 'Cancel a proposal (ADMIN only)',
Expand Down
70 changes: 70 additions & 0 deletions src/database/migrations/1782382000000-CreateFeatureFlagsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';

export class CreateFeatureFlagsTable1782382000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'feature_flags',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'uuid_generate_v4()',
},
{
name: 'key',
type: 'varchar',
isUnique: true,
},
{
name: 'name',
type: 'varchar',
},
{
name: 'description',
type: 'text',
isNullable: true,
},
{
name: 'isEnabled',
type: 'boolean',
default: false,
},
{
name: 'rolloutPercent',
type: 'int',
default: 0,
},
{
name: 'targetUserIds',
type: 'uuid',
isArray: true,
isNullable: true,
},
{
name: 'environments',
type: 'varchar',
isArray: true,
default: "'{}'",
},
{
name: 'createdAt',
type: 'timestamp with time zone',
default: 'now()',
},
{
name: 'updatedAt',
type: 'timestamp with time zone',
default: 'now()',
},
],
}),
true,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('feature_flags');
}
}
40 changes: 40 additions & 0 deletions src/modules/flags/entities/feature-flag.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';

@Entity('feature_flags')
export class FeatureFlag {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ type: 'varchar', unique: true })
key: string;

@Column({ type: 'varchar' })
name: string;

@Column({ type: 'text', nullable: true })
description: string;

@Column({ type: 'boolean', default: false })
isEnabled: boolean;

@Column({ type: 'int', default: 0 })
rolloutPercent: number;

@Column({ type: 'uuid', array: true, nullable: true })
targetUserIds: string[];

@Column({ type: 'varchar', array: true, default: [] })
environments: string[];

@CreateDateColumn({ type: 'timestamp with time zone' })
createdAt: Date;

@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
}
60 changes: 60 additions & 0 deletions src/modules/flags/flags.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FlagsController } from './flags.controller';

describe('FlagsController', () => {
let controller: FlagsController;
let service: any;

beforeEach(() => {
service = {
getFlagsForUser: jest.fn(),
listFlags: jest.fn(),
createFlag: jest.fn(),
updateFlag: jest.fn(),
deleteFlag: jest.fn(),
isEnabled: jest.fn(),
};
controller = new FlagsController(service);
});

it('should call getFlagsForUser', async () => {
service.getFlagsForUser.mockResolvedValue({ flag1: true });
expect(await controller.getUserFlags({ user: { userId: '1' } })).toEqual({
flag1: true,
});
});

it('should call listFlags', async () => {
service.listFlags.mockResolvedValue([]);
expect(await controller.listFlags()).toEqual([]);
});

it('should call createFlag', async () => {
service.createFlag.mockResolvedValue({});
expect(await controller.createFlag({})).toEqual({});
});

it('should call updateFlag', async () => {
service.updateFlag.mockResolvedValue({});
expect(await controller.updateFlag('1', {})).toEqual({});
});

it('should call deleteFlag', async () => {
service.deleteFlag.mockResolvedValue(undefined);
expect(await controller.deleteFlag('1')).toEqual({ success: true });
});

it('should return enabled state from checkFlagForUser', async () => {
service.listFlags.mockResolvedValue([{ id: '1', key: 'flag1' }]);
service.isEnabled.mockResolvedValue(true);
expect(await controller.checkFlagForUser('1', 'user1')).toEqual({
enabled: true,
});
});

it('should return enabled false if flag not found in checkFlagForUser', async () => {
service.listFlags.mockResolvedValue([]);
expect(await controller.checkFlagForUser('1', 'user1')).toEqual({
enabled: false,
});
});
});
Loading
Loading