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
23 changes: 23 additions & 0 deletions src/common/decorators/current-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export interface AuthenticatedUser {
userId: string;
username: string;
}

/**
* Extracts the authenticated caller from the request, as populated by
* JwtAuthGuard/JwtStrategy. Throws if used without an auth guard so callers
* don't silently read an undefined user.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
const request = ctx
.switchToHttp()
.getRequest<{ user?: AuthenticatedUser }>();
if (!request.user) {
throw new Error('CurrentUser can only be used behind an auth guard');
}
return request.user;
},
);
47 changes: 42 additions & 5 deletions src/reputation/reputation.controller.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
import { Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ReputationService } from './reputation.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/decorators/current-user.decorator';

@ApiTags('reputation')
@Controller('reputation')
export class ReputationController {
constructor(private readonly reputationService: ReputationService) {}

private assertOwner(user: AuthenticatedUser, userId: string) {
if (user.userId !== userId) {
throw new ForbiddenException(
'You may only access or recompute your own reputation data',
);
}
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post(':userId/recompute')
recompute(@Param('userId', new ParseUUIDPipe()) userId: string) {
recompute(
@Param('userId', new ParseUUIDPipe()) userId: string,
@CurrentUser() user: AuthenticatedUser,
) {
this.assertOwner(user, userId);
return this.reputationService.computeAndSave(userId);
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':userId')
latest(@Param('userId', new ParseUUIDPipe()) userId: string) {
latest(
@Param('userId', new ParseUUIDPipe()) userId: string,
@CurrentUser() user: AuthenticatedUser,
) {
this.assertOwner(user, userId);
return this.reputationService.getLatest(userId);
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':userId/history')
history(@Param('userId', new ParseUUIDPipe()) userId: string) {
history(
@Param('userId', new ParseUUIDPipe()) userId: string,
@CurrentUser() user: AuthenticatedUser,
) {
this.assertOwner(user, userId);
return this.reputationService.history(userId);
}
}
38 changes: 34 additions & 4 deletions src/sponsors/sponsors.controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,49 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
Controller,
ForbiddenException,
Get,
Param,
ParseUUIDPipe,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SponsorsService } from './sponsors.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/decorators/current-user.decorator';

@ApiTags('sponsors')
@Controller('sponsors')
export class SponsorsController {
constructor(private readonly sponsorsService: SponsorsService) {}

private assertOwnsSponsor(user: AuthenticatedUser, sponsorId: string) {
if (user.userId !== sponsorId) {
throw new ForbiddenException(
'You may only view your own sponsor dashboard and progress',
);
}
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/dashboard')
dashboard(@Param('id', new ParseUUIDPipe()) id: string) {
dashboard(
@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser() user: AuthenticatedUser,
) {
this.assertOwnsSponsor(user, id);
return this.sponsorsService.dashboard(id);
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/milestones/progress')
milestoneProgress(@Param('id', new ParseUUIDPipe()) id: string) {
milestoneProgress(
@Param('id', new ParseUUIDPipe()) id: string,
@CurrentUser() user: AuthenticatedUser,
) {
this.assertOwnsSponsor(user, id);
return this.sponsorsService.milestoneProgress(id);
}
}
30 changes: 0 additions & 30 deletions src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,36 +179,6 @@ describe('UsersService', () => {
});
});

describe('addRole', () => {
it('adds the role when the user does not already have it', async () => {
userRepo.findOne.mockResolvedValue({
id: 'u1',
roles: [UserRole.CONTRIBUTOR],
});

const user = await service.addRole('u1', UserRole.MAINTAINER);

expect(user.roles).toEqual([UserRole.CONTRIBUTOR, UserRole.MAINTAINER]);
expect(userRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
roles: [UserRole.CONTRIBUTOR, UserRole.MAINTAINER],
}),
);
});

it('is a no-op when the user already has the role', async () => {
userRepo.findOne.mockResolvedValue({
id: 'u1',
roles: [UserRole.CONTRIBUTOR],
});

const user = await service.addRole('u1', UserRole.CONTRIBUTOR);

expect(user.roles).toEqual([UserRole.CONTRIBUTOR]);
expect(userRepo.save).not.toHaveBeenCalled();
});
});

describe('setStellarAddress', () => {
it('throws NotFoundException when the user does not exist', async () => {
userRepo.findOne.mockResolvedValue(null);
Expand Down
15 changes: 7 additions & 8 deletions src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,13 @@ export class UsersService {
return this.findOneRaw(user.id);
}

async addRole(userId: string, role: UserRole): Promise<User> {
const user = await this.findOneRaw(userId);
if (!user.roles.includes(role)) {
user.roles = [...user.roles, role];
await this.userRepo.save(user);
}
return user;
}
// Role assignment is intentionally out of scope for this version: a User's
// `roles` are seeded to [UserRole.CONTRIBUTOR] at creation (upsertFromGithub)
// and are not mutated by any API endpoint or admin flow today. The
// MAINTAINER / SPONSOR roles remain declared but unreachable until a
// guarded role-assignment endpoint (or sponsor-verification flow) is added.
// Keeping role mutation out of the public service surface prevents a reader
// from mistaking an unused method for a working feature.

async setStellarAddress(
userId: string,
Expand Down
8 changes: 4 additions & 4 deletions test/users.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('UsersController (e2e)', () => {
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => false }) // Simulate unauthenticated
.useValue({ canActivate: () => false }) // Simulate a guard-denied request (always 403)
.compile();

app = moduleFixture.createNestApplication();
Expand All @@ -33,15 +33,15 @@ describe('UsersController (e2e)', () => {
});

describe('GET /users', () => {
it('should reject unauthenticated requests with 401', () => {
it('should return 403 when the guard denies the request', () => {
return request(app.getHttpServer())
.get('/users')
.expect(403); // Assuming the guard returns 403 when not authorized
.expect(403);
});
});

describe('GET /users/:id', () => {
it('should reject unauthenticated requests with 401', () => {
it('should return 403 when the guard denies the request', () => {
return request(app.getHttpServer())
.get('/users/00000000-0000-0000-0000-000000000000')
.expect(403);
Expand Down
Loading