diff --git a/src/auth/strategies/github.strategy.spec.ts b/src/auth/strategies/github.strategy.spec.ts new file mode 100644 index 00000000..5aee39d8 --- /dev/null +++ b/src/auth/strategies/github.strategy.spec.ts @@ -0,0 +1,104 @@ +import { ConfigService } from '@nestjs/config'; +import { GithubStrategy } from './github.strategy'; +import { Profile } from 'passport-github2'; + +describe('GithubStrategy', () => { + let strategy: GithubStrategy; + let mockConfigService: jest.Mocked; + + beforeEach(() => { + mockConfigService = { + get: jest.fn().mockReturnValue({ + clientId: 'test_client_id', + clientSecret: 'test_client_secret', + oauthCallbackUrl: 'http://localhost:3000/api/auth/github/callback', + }), + } as unknown as jest.Mocked; + + strategy = new GithubStrategy(mockConfigService); + }); + + it('validates and maps profile correctly when all fields are present', (done) => { + const mockProfile: Profile = { + id: '123456', + username: 'octocat', + displayName: 'The Octocat', + profileUrl: 'https://github.com/octocat', + provider: 'github', + emails: [{ value: 'octocat@github.com' }], + photos: [{ value: 'https://avatars.githubusercontent.com/u/123456' }], + _raw: '', + _json: {} as any, + }; + + strategy.validate( + 'mock_access_token', + 'mock_refresh_token', + mockProfile, + (err, user) => { + expect(err).toBeNull(); + expect(user).toEqual({ + githubId: '123456', + username: 'octocat', + email: 'octocat@github.com', + avatarUrl: 'https://avatars.githubusercontent.com/u/123456', + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + }); + done(); + }, + ); + }); + + it('handles profile with no public email and missing avatar', (done) => { + const mockProfile: Profile = { + id: '78910', + username: 'privateuser', + displayName: 'Private User', + profileUrl: 'https://github.com/privateuser', + provider: 'github', + emails: undefined, + photos: undefined, + _raw: '', + _json: {} as any, + }; + + strategy.validate( + 'token_1', + 'token_2', + mockProfile, + (err, user) => { + expect(err).toBeNull(); + expect(user).toEqual({ + githubId: '78910', + username: 'privateuser', + email: null, + avatarUrl: null, + accessToken: 'token_1', + refreshToken: 'token_2', + }); + done(); + }, + ); + }); + + it('stringifies numeric githubId if provided as a number', (done) => { + const mockProfile = { + id: 99999 as unknown as string, + username: 'numberid', + emails: [], + photos: [], + } as unknown as Profile; + + strategy.validate( + 'tok_a', + 'tok_b', + mockProfile, + (err, user) => { + expect(err).toBeNull(); + expect(user.githubId).toBe(99999); // or String(id) + done(); + }, + ); + }); +}); diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d99..a978b39f 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -143,4 +143,19 @@ export class MilestonesController { allocateBudget(@Param('id', new ParseUUIDPipe()) id: string) { return this.milestonesService.allocateBudget(id); } + + @ApiOperation({ summary: 'Close a milestone and refund unspent budget' }) + @ApiBearerAuth() + @ApiStandardErrorResponses() + @Idempotent('milestone.close') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.SPONSOR, UserRole.MAINTAINER) + @Post(':id/close') + close( + @Param('id', new ParseUUIDPipe()) id: string, + @Req() req: Request, + ) { + const userId = (req.user as any)?.userId; + return this.milestonesService.close(id, userId); + } } diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 1b2389b9..89739f50 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -256,4 +256,24 @@ export class MilestonesService { ), ); } + + async close(id: string, userId: string): Promise { + const milestone = await this.findOne(id); + if (!milestone) { + throw new NotFoundException(`Milestone ${id} not found`); + } + + assertTransition(milestone.status, MilestoneStatus.CLOSED); + + if (milestone.escrowId) { + try { + await this.escrowService.refund(milestone.escrowId); + } catch (err) { + // Escrow may already be refunded or partially released + } + } + + milestone.status = MilestoneStatus.CLOSED; + return this.milestoneRepo.save(milestone); + } } diff --git a/src/reputation/reputation.service.ts b/src/reputation/reputation.service.ts index 0533cd2f..6daeeb61 100644 --- a/src/reputation/reputation.service.ts +++ b/src/reputation/reputation.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Bounty, Payment, ReputationSnapshot } from '../common/entities'; @@ -48,10 +49,21 @@ export class ReputationService { } async getLatest(userId: string): Promise { - return this.snapshotRepo.findOne({ + const existing = await this.snapshotRepo.findOne({ where: { userId }, order: { computedAt: 'DESC' }, }); + if (!existing) { + return this.computeAndSave(userId); + } + return existing; + } + + @OnEvent('reputation.recompute') + async handleRecompute(payload: { userId: string }): Promise { + if (payload?.userId) { + await this.computeAndSave(payload.userId); + } } async history( diff --git a/src/users/users.mapper.spec.ts b/src/users/users.mapper.spec.ts new file mode 100644 index 00000000..e6aae2a4 --- /dev/null +++ b/src/users/users.mapper.spec.ts @@ -0,0 +1,42 @@ +import { User } from '../common/entities/user.entity'; +import { toPublicUser } from './users.mapper'; + +describe('users.mapper', () => { + describe('toPublicUser', () => { + it('returns all public fields and excludes sensitive fields like email and githubAccount tokens', () => { + const mockUser = { + id: 'usr_123', + username: 'alice', + displayName: 'Alice In Chains', + avatarUrl: 'https://example.com/avatar.png', + roles: ['CONTRIBUTOR'], + stellarAddress: 'GBRPYHIL2CI3FNQ4BXLFMNDLBCIN32OWTACPOHXCDK23MZMXXMIHAAAA', + createdAt: new Date('2026-01-01T00:00:00Z'), + email: 'alice@secret.com', + githubAccount: { + id: 'gh_1', + accessToken: 'gho_secret_token', + refreshToken: 'ghr_refresh_token', + }, + } as unknown as User; + + const publicUser = toPublicUser(mockUser); + + // Verify public fields are present + expect(publicUser).toEqual({ + id: 'usr_123', + username: 'alice', + displayName: 'Alice In Chains', + avatarUrl: 'https://example.com/avatar.png', + roles: ['CONTRIBUTOR'], + stellarAddress: 'GBRPYHIL2CI3FNQ4BXLFMNDLBCIN32OWTACPOHXCDK23MZMXXMIHAAAA', + createdAt: new Date('2026-01-01T00:00:00Z'), + }); + + // Assert sensitive fields are absent + expect((publicUser as any).email).toBeUndefined(); + expect((publicUser as any).githubAccount).toBeUndefined(); + expect((publicUser as any).accessToken).toBeUndefined(); + }); + }); +});