diff --git a/src/github/github-sync.service.ts b/src/github/github-sync.service.ts index 6478ec2..7d5b90f 100644 --- a/src/github/github-sync.service.ts +++ b/src/github/github-sync.service.ts @@ -1,4 +1,10 @@ -import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Octokit } from '@octokit/rest'; import { Repository as TypeOrmRepository } from 'typeorm'; @@ -224,6 +230,15 @@ export class GithubSyncService { return this.repositoryRepo.findOne({ where: { githubRepoId } }); } + async findRepositoryByOwnerAndName( + owner: string, + name: string, + ): Promise { + return this.repositoryRepo.findOne({ + where: { owner, name }, + }); + } + /** Fetches a single PR's merge status directly from the API (used by webhook fallback verification). */ async getPullRequest(owner: string, repo: string, pullNumber: number) { const { data } = await this.octokit.pulls.get({ diff --git a/src/github/github.controller.spec.ts b/src/github/github.controller.spec.ts new file mode 100644 index 0000000..4809ece --- /dev/null +++ b/src/github/github.controller.spec.ts @@ -0,0 +1,96 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GithubController } from './github.controller'; +import { GithubSyncService } from './github-sync.service'; +import { UserRole } from '../common/enums'; +import { Repository, User } from '../common/entities'; + +describe('GithubController', () => { + let controller: GithubController; + let syncService: { + syncRepository: jest.Mock; + findRepositoryByOwnerAndName: jest.Mock; + }; + + beforeEach(async () => { + syncService = { + syncRepository: jest.fn(), + findRepositoryByOwnerAndName: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [GithubController], + providers: [{ provide: GithubSyncService, useValue: syncService }], + }).compile(); + + controller = module.get(GithubController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('rejects unprivileged user role (e.g. contributor only)', async () => { + const mockReq = { + user: { + id: 'u-1', + roles: [UserRole.CONTRIBUTOR], + } as User, + } as any; + + await expect( + controller.sync('acme', 'widgets', mockReq), + ).rejects.toThrow(ForbiddenException); + expect(syncService.syncRepository).not.toHaveBeenCalled(); + }); + + it('rejects sync for untracked repository', async () => { + const mockReq = { + user: { + id: 'u-2', + roles: [UserRole.MAINTAINER], + } as User, + } as any; + + syncService.findRepositoryByOwnerAndName.mockResolvedValue(null); + + await expect( + controller.sync('torvalds', 'linux', mockReq), + ).rejects.toThrow(NotFoundException); + expect(syncService.syncRepository).not.toHaveBeenCalled(); + }); + + it('allows maintainer to sync tracked repository', async () => { + const mockReq = { + user: { + id: 'u-2', + roles: [UserRole.MAINTAINER], + } as User, + } as any; + + const mockRepo = { id: 'repo-1', owner: 'acme', name: 'widgets' } as Repository; + syncService.findRepositoryByOwnerAndName.mockResolvedValue(mockRepo); + syncService.syncRepository.mockResolvedValue(mockRepo); + + const result = await controller.sync('acme', 'widgets', mockReq); + expect(result).toEqual(mockRepo); + expect(syncService.findRepositoryByOwnerAndName).toHaveBeenCalledWith('acme', 'widgets'); + expect(syncService.syncRepository).toHaveBeenCalledWith('acme', 'widgets'); + }); + + it('allows sponsor to sync tracked repository', async () => { + const mockReq = { + user: { + id: 'u-3', + roles: [UserRole.SPONSOR], + } as User, + } as any; + + const mockRepo = { id: 'repo-1', owner: 'acme', name: 'widgets' } as Repository; + syncService.findRepositoryByOwnerAndName.mockResolvedValue(mockRepo); + syncService.syncRepository.mockResolvedValue(mockRepo); + + const result = await controller.sync('acme', 'widgets', mockReq); + expect(result).toEqual(mockRepo); + }); +}); diff --git a/src/github/github.controller.ts b/src/github/github.controller.ts index 04e9ba0..698c017 100644 --- a/src/github/github.controller.ts +++ b/src/github/github.controller.ts @@ -1,14 +1,60 @@ -import { Controller, Param, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { + Controller, + ForbiddenException, + NotFoundException, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { GithubSyncService } from './github-sync.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserRole } from '../common/enums'; +import { User } from '../common/entities'; + +interface RequestWithUser extends Request { + user: User; +} @ApiTags('github') @Controller('github') export class GithubController { constructor(private readonly syncService: GithubSyncService) {} + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @Post('sync/:owner/:repo') - sync(@Param('owner') owner: string, @Param('repo') repo: string) { + async sync( + @Param('owner') owner: string, + @Param('repo') repo: string, + @Req() req: RequestWithUser, + ) { + const user = req.user; + const isMaintainerOrAdmin = + user?.roles && + (user.roles.includes(UserRole.MAINTAINER) || + user.roles.includes(UserRole.SPONSOR) || + (user.roles as unknown as string[]).includes('admin')); + + if (!isMaintainerOrAdmin) { + throw new ForbiddenException( + 'Only maintainers or sponsors may trigger repository synchronization', + ); + } + + const tracked = await this.syncService.findRepositoryByOwnerAndName( + owner, + repo, + ); + if (!tracked) { + throw new NotFoundException( + `Repository ${owner}/${repo} is not tracked by MergeFi. Only registered repositories can be synced.`, + ); + } + return this.syncService.syncRepository(owner, repo); } }