diff --git a/src/analytics/analytics.controller.spec.ts b/src/analytics/analytics.controller.spec.ts new file mode 100644 index 00000000..41f1f86f --- /dev/null +++ b/src/analytics/analytics.controller.spec.ts @@ -0,0 +1,71 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { AuthenticatedUser } from '../common/decorators/current-user.decorator'; +import { UserRole } from '../common/enums'; + +describe('AnalyticsController', () => { + let controller: AnalyticsController; + let analyticsService: { + forContributor: jest.Mock; + platformSummary: jest.Mock; + }; + + const userAlice: AuthenticatedUser = { + userId: 'user-1', + username: 'alice', + roles: [UserRole.CONTRIBUTOR], + }; + + const userBob: AuthenticatedUser = { + userId: 'user-2', + username: 'bob', + roles: [UserRole.CONTRIBUTOR], + }; + + beforeEach(async () => { + analyticsService = { + forContributor: jest.fn().mockResolvedValue({ userId: 'user-1' }), + platformSummary: jest.fn().mockResolvedValue({ totalBounties: 10 }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [{ provide: AnalyticsService, useValue: analyticsService }], + }).compile(); + + controller = module.get(AnalyticsController); + }); + + describe('forContributor', () => { + it('returns analytics for the user', async () => { + const res = await controller.forContributor( + 'user-1', + userAlice, + '2024-01-01', + '2024-06-01', + ); + expect(res).toEqual({ userId: 'user-1' }); + expect(analyticsService.forContributor).toHaveBeenCalledWith('user-1', { + from: '2024-01-01', + to: '2024-06-01', + }); + }); + + it('throws 403 ForbiddenException when accessing another contributor analytics', async () => { + expect(() => + controller.forContributor('user-1', userBob, undefined, undefined), + ).toThrow(ForbiddenException); + expect(analyticsService.forContributor).not.toHaveBeenCalled(); + }); + }); + + describe('platformSummary', () => { + it('returns platform-wide analytics summary', async () => { + const res = await controller.platformSummary(); + expect(res).toEqual({ totalBounties: 10 }); + expect(analyticsService.platformSummary).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/analytics/ttl-cache.spec.ts b/src/analytics/ttl-cache.spec.ts new file mode 100644 index 00000000..6e70f666 --- /dev/null +++ b/src/analytics/ttl-cache.spec.ts @@ -0,0 +1,79 @@ +import { TtlCache } from './ttl-cache'; + +describe('TtlCache', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('loads and caches data for the duration of the TTL', async () => { + const cache = new TtlCache(5000); + const loader = jest.fn().mockResolvedValue('value-1'); + + const result1 = await cache.getOrLoad(loader); + expect(result1).toBe('value-1'); + expect(loader).toHaveBeenCalledTimes(1); + + // Call within TTL should return cached value + jest.advanceTimersByTime(2000); + const result2 = await cache.getOrLoad(loader); + expect(result2).toBe('value-1'); + expect(loader).toHaveBeenCalledTimes(1); + + // After TTL expires, loader should be called again + jest.advanceTimersByTime(4000); + loader.mockResolvedValueOnce('value-2'); + const result3 = await cache.getOrLoad(loader); + expect(result3).toBe('value-2'); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('coalesces concurrent misses so loader is only called once', async () => { + const cache = new TtlCache(5000); + let resolveLoader!: (val: string) => void; + const loader = jest.fn( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + const promise1 = cache.getOrLoad(loader); + const promise2 = cache.getOrLoad(loader); + + resolveLoader('concurrent-result'); + + const [res1, res2] = await Promise.all([promise1, promise2]); + expect(res1).toBe('concurrent-result'); + expect(res2).toBe('concurrent-result'); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('handles invalidate while load is in flight', async () => { + const cache = new TtlCache(5000); + let resolveLoader!: (val: string) => void; + const loader = jest.fn( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + const promise1 = cache.getOrLoad(loader); + cache.invalidate(); + + resolveLoader('loaded-data'); + const res = await promise1; + expect(res).toBe('loaded-data'); + + // Invalidate should have cleared entry, or subsequent call fetches if expired/invalidated + cache.invalidate(); + loader.mockResolvedValueOnce('fresh-data'); + const res2 = await cache.getOrLoad(loader); + expect(res2).toBe('fresh-data'); + expect(loader).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.spec.ts b/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 00000000..17f7b23b --- /dev/null +++ b/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,110 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ExtractJwt } from 'passport-jwt'; +import { Request } from 'express'; +import { JwtStrategy, cookieExtractor } from './jwt.strategy'; +import { UsersService } from '../../users/users.service'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + let usersService: { findOneRaw: jest.Mock }; + let configService: { get: jest.Mock }; + + beforeEach(async () => { + usersService = { + findOneRaw: jest.fn().mockResolvedValue({ id: 'user-1' }), + }; + configService = { + get: jest.fn().mockReturnValue({ secret: 'test-secret' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { provide: UsersService, useValue: usersService }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + strategy = module.get(JwtStrategy); + }); + + describe('cookieExtractor', () => { + it('returns token from req.cookies if populated', () => { + const req = { + cookies: { access_token: 'cookie-token' }, + } as unknown as Request; + expect(cookieExtractor(req)).toBe('cookie-token'); + }); + + it('extracts token from cookie header when req.cookies is not available', () => { + const req = { + headers: { cookie: 'other=val; access_token=header-cookie-token' }, + } as unknown as Request; + expect(cookieExtractor(req)).toBe('header-cookie-token'); + }); + + it('returns null on malformed encoded cookie value', () => { + const req = { + headers: { cookie: 'access_token=%invalid' }, + } as unknown as Request; + expect(cookieExtractor(req)).toBeNull(); + }); + + it('returns null if no cookie header or access_token present', () => { + const req = { headers: {} } as unknown as Request; + expect(cookieExtractor(req)).toBeNull(); + }); + }); + + describe('bearer vs cookie precedence', () => { + it('prefers Bearer token in Authorization header over cookie', () => { + const extractor = ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + cookieExtractor, + ]); + + const reqWithBoth = { + headers: { + authorization: 'Bearer bearer-token-123', + cookie: 'access_token=cookie-token-456', + }, + } as unknown as Request; + + expect(extractor(reqWithBoth)).toBe('bearer-token-123'); + + const reqWithCookieOnly = { + headers: { + cookie: 'access_token=cookie-token-456', + }, + } as unknown as Request; + + expect(extractor(reqWithCookieOnly)).toBe('cookie-token-456'); + }); + }); + + describe('validate', () => { + it('returns userId and username for valid payload', async () => { + const result = await strategy.validate({ + sub: 'user-1', + username: 'alice', + }); + expect(result).toEqual({ userId: 'user-1', username: 'alice' }); + expect(usersService.findOneRaw).toHaveBeenCalledWith('user-1'); + }); + + it('throws UnauthorizedException when sub is missing', async () => { + await expect( + strategy.validate({ sub: '', username: 'alice' } as any), + ).rejects.toThrow(new UnauthorizedException('Invalid token payload')); + }); + + it('throws 401 UnauthorizedException when user no longer exists', async () => { + usersService.findOneRaw.mockRejectedValue(new Error('User not found')); + await expect( + strategy.validate({ sub: 'deleted-user', username: 'alice' }), + ).rejects.toThrow(new UnauthorizedException('User no longer exists')); + }); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index bf22c5a6..433ebbfe 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -11,7 +11,7 @@ export interface JwtPayload { username: string; } -function cookieExtractor(req: Request): string | null { +export function cookieExtractor(req: Request): string | null { if (!req) return null; // If cookie-parser is installed, req.cookies will be populated. const cookies = (req as unknown as { cookies?: Record }) diff --git a/src/maintenance-pool/maintenance-pool.controller.spec.ts b/src/maintenance-pool/maintenance-pool.controller.spec.ts new file mode 100644 index 00000000..f2b8f052 --- /dev/null +++ b/src/maintenance-pool/maintenance-pool.controller.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MaintenancePoolController } from './maintenance-pool.controller'; +import { MaintenancePoolService } from './maintenance-pool.service'; +import { Request } from 'express'; + +describe('MaintenancePoolController', () => { + let controller: MaintenancePoolController; + let poolService: { + create: jest.Mock; + list: jest.Mock; + findOne: jest.Mock; + deposit: jest.Mock; + assignReward: jest.Mock; + }; + + beforeEach(async () => { + poolService = { + create: jest.fn().mockResolvedValue({ id: 'pool-1' }), + list: jest.fn().mockResolvedValue([{ id: 'pool-1' }]), + findOne: jest.fn().mockResolvedValue({ id: 'pool-1' }), + deposit: jest.fn().mockResolvedValue({ id: 'pool-1' }), + assignReward: jest.fn().mockResolvedValue({ id: 'pool-1' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [MaintenancePoolController], + providers: [{ provide: MaintenancePoolService, useValue: poolService }], + }).compile(); + + controller = module.get(MaintenancePoolController); + }); + + it('delegates create to poolService with caller userId', async () => { + const req = { user: { userId: 'user-1' } } as unknown as Request; + const dto = { repositoryId: 'repo-1', targetAmount: '100' } as any; + + const res = await controller.create(dto, req); + expect(res).toEqual({ id: 'pool-1' }); + expect(poolService.create).toHaveBeenCalledWith(dto, 'user-1'); + }); + + it('delegates list to poolService', async () => { + const res = await controller.list(); + expect(res).toEqual([{ id: 'pool-1' }]); + expect(poolService.list).toHaveBeenCalled(); + }); + + it('delegates findOne to poolService', async () => { + const res = await controller.findOne('pool-1'); + expect(res).toEqual({ id: 'pool-1' }); + expect(poolService.findOne).toHaveBeenCalledWith('pool-1'); + }); + + it('delegates deposit to poolService', async () => { + const res = await controller.deposit('pool-1', { + amount: '50.00', + funderAddress: 'GFUNDER', + }); + expect(res).toEqual({ id: 'pool-1' }); + expect(poolService.deposit).toHaveBeenCalledWith('pool-1', '50.00', 'GFUNDER'); + }); + + it('delegates assignReward to poolService', async () => { + const res = await controller.assignReward('pool-1', { + issueId: 'issue-1', + amount: '20.00', + recipientAddress: 'GRECIPIENT', + recipientId: 'user-2', + }); + expect(res).toEqual({ id: 'pool-1' }); + expect(poolService.assignReward).toHaveBeenCalledWith( + 'pool-1', + 'issue-1', + '20.00', + 'GRECIPIENT', + 'user-2', + ); + }); +}); diff --git a/src/reputation/reputation.controller.spec.ts b/src/reputation/reputation.controller.spec.ts new file mode 100644 index 00000000..f76225a3 --- /dev/null +++ b/src/reputation/reputation.controller.spec.ts @@ -0,0 +1,90 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ReputationController } from './reputation.controller'; +import { ReputationService } from './reputation.service'; +import { AuthenticatedUser } from '../common/decorators/current-user.decorator'; +import { UserRole } from '../common/enums'; + +describe('ReputationController', () => { + let controller: ReputationController; + let reputationService: { + computeAndSave: jest.Mock; + getLatest: jest.Mock; + history: jest.Mock; + }; + + const userAlice: AuthenticatedUser = { + userId: 'user-1', + username: 'alice', + roles: [UserRole.CONTRIBUTOR], + }; + + const userBob: AuthenticatedUser = { + userId: 'user-2', + username: 'bob', + roles: [UserRole.CONTRIBUTOR], + }; + + beforeEach(async () => { + reputationService = { + computeAndSave: jest.fn().mockResolvedValue({ id: 'snapshot-1' }), + getLatest: jest.fn().mockResolvedValue({ id: 'snapshot-1' }), + history: jest.fn().mockResolvedValue([{ id: 'snapshot-1' }]), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReputationController], + providers: [{ provide: ReputationService, useValue: reputationService }], + }).compile(); + + controller = module.get(ReputationController); + }); + + describe('recompute', () => { + it('allows owner to recompute reputation', async () => { + const res = await controller.recompute('user-1', userAlice); + expect(res).toEqual({ id: 'snapshot-1' }); + expect(reputationService.computeAndSave).toHaveBeenCalledWith('user-1'); + }); + + it('throws 403 ForbiddenException when caller is not owner', async () => { + expect(() => controller.recompute('user-1', userBob)).toThrow( + ForbiddenException, + ); + expect(reputationService.computeAndSave).not.toHaveBeenCalled(); + }); + }); + + describe('latest', () => { + it('returns latest snapshot for owner', async () => { + const res = await controller.latest('user-1', userAlice); + expect(res).toEqual({ id: 'snapshot-1' }); + expect(reputationService.getLatest).toHaveBeenCalledWith('user-1'); + }); + + it('throws 403 ForbiddenException when caller is not owner', async () => { + expect(() => controller.latest('user-1', userBob)).toThrow( + ForbiddenException, + ); + expect(reputationService.getLatest).not.toHaveBeenCalled(); + }); + }); + + describe('history', () => { + it('returns history for owner with parsed pagination', async () => { + const res = await controller.history('user-1', userAlice, '20', '10'); + expect(res).toEqual([{ id: 'snapshot-1' }]); + expect(reputationService.history).toHaveBeenCalledWith('user-1', { + limit: 20, + offset: 10, + }); + }); + + it('throws 403 ForbiddenException when caller is not owner', async () => { + expect(() => controller.history('user-1', userBob)).toThrow( + ForbiddenException, + ); + expect(reputationService.history).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/sponsors/sponsors.controller.spec.ts b/src/sponsors/sponsors.controller.spec.ts new file mode 100644 index 00000000..300ae41a --- /dev/null +++ b/src/sponsors/sponsors.controller.spec.ts @@ -0,0 +1,99 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SponsorsController, validatePage } from './sponsors.controller'; +import { SponsorsService } from './sponsors.service'; +import { AuthenticatedUser } from '../common/decorators/current-user.decorator'; +import { UserRole } from '../common/enums'; + +describe('SponsorsController', () => { + let controller: SponsorsController; + let sponsorsService: { + dashboard: jest.Mock; + milestoneProgress: jest.Mock; + }; + + const userAlice: AuthenticatedUser = { + userId: 'sponsor-1', + username: 'alice', + roles: [UserRole.SPONSOR], + }; + + const userBob: AuthenticatedUser = { + userId: 'sponsor-2', + username: 'bob', + roles: [UserRole.SPONSOR], + }; + + beforeEach(async () => { + sponsorsService = { + dashboard: jest.fn().mockResolvedValue({ id: 'dashboard-data' }), + milestoneProgress: jest.fn().mockResolvedValue({ id: 'progress-data' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SponsorsController], + providers: [{ provide: SponsorsService, useValue: sponsorsService }], + }).compile(); + + controller = module.get(SponsorsController); + }); + + describe('dashboard', () => { + it('returns dashboard data for the owner', async () => { + const res = await controller.dashboard('sponsor-1', userAlice, 10, 0); + expect(res).toEqual({ id: 'dashboard-data' }); + expect(sponsorsService.dashboard).toHaveBeenCalledWith('sponsor-1', { + limit: 10, + offset: 0, + }); + }); + + it('throws 403 ForbiddenException when accessing another sponsor dashboard', async () => { + expect(() => + controller.dashboard('sponsor-1', userBob, 10, 0), + ).toThrow(ForbiddenException); + expect(sponsorsService.dashboard).not.toHaveBeenCalled(); + }); + }); + + describe('milestoneProgress', () => { + it('returns milestone progress for the owner', async () => { + const res = await controller.milestoneProgress( + 'sponsor-1', + userAlice, + 20, + 5, + ); + expect(res).toEqual({ id: 'progress-data' }); + expect(sponsorsService.milestoneProgress).toHaveBeenCalledWith( + 'sponsor-1', + { limit: 20, offset: 5 }, + ); + }); + + it('throws 403 ForbiddenException when accessing another sponsor milestone progress', async () => { + expect(() => + controller.milestoneProgress('sponsor-1', userBob, 20, 5), + ).toThrow(ForbiddenException); + expect(sponsorsService.milestoneProgress).not.toHaveBeenCalled(); + }); + }); + + describe('validatePage', () => { + it('accepts valid limit and offset', () => { + expect(validatePage(25, 10)).toEqual({ limit: 25, offset: 10 }); + }); + + it('throws BadRequestException for negative offset', () => { + expect(() => validatePage(10, -1)).toThrow(BadRequestException); + }); + + it('throws BadRequestException for limit > MAX_PAGE_LIMIT', () => { + expect(() => validatePage(101, 0)).toThrow(BadRequestException); + }); + + it('throws BadRequestException for limit < 1', () => { + expect(() => validatePage(0, 0)).toThrow(BadRequestException); + }); + }); +}); diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index b7a48cca..1b590f87 100644 --- a/src/sponsors/sponsors.controller.ts +++ b/src/sponsors/sponsors.controller.ts @@ -18,7 +18,7 @@ import { ApiInternalErrorResponse } from '../common/swagger/api-common-responses const MAX_PAGE_LIMIT = 100; -function validatePage(limit?: number, offset?: number) { +export function validatePage(limit?: number, offset?: number) { if (limit !== undefined && (limit < 1 || limit > MAX_PAGE_LIMIT)) { throw new BadRequestException(`limit must be between 1 and ${MAX_PAGE_LIMIT}`); } @@ -26,25 +26,6 @@ function validatePage(limit?: number, offset?: number) { throw new BadRequestException('offset must be >= 0'); } return { limit, offset }; -/** - * Parse optional limit/offset query params, rejecting NaN, negatives, - * non-integers, and limits above MAX_PAGE_LIMIT with a 400 (#280). - */ -function parsePagination(limit?: string, offset?: string) { - const parse = (name: string, raw: string | undefined, min: number, max: number) => { - if (raw === undefined || raw === '') return undefined; - const n = Number(raw); - if (!Number.isInteger(n) || n < min || n > max) { - throw new BadRequestException( - `${name} must be an integer between ${min} and ${max}`, - ); - } - return n; - }; - return { - limit: parse('limit', limit, 1, MAX_PAGE_LIMIT), - offset: parse('offset', offset, 0, Number.MAX_SAFE_INTEGER), - }; } @ApiTags('sponsors') @@ -73,7 +54,6 @@ export class SponsorsController { ) { this.assertOwnsSponsor(user, id); return this.sponsorsService.dashboard(id, validatePage(limit, offset)); - return this.sponsorsService.dashboard(id, parsePagination(limit, offset)); } @ApiBearerAuth() @@ -91,6 +71,5 @@ export class SponsorsController { id, validatePage(limit, offset), ); - return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset)); } } diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 00000000..c5938269 --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,96 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { PublicUserDto } from './dto/public-user.dto'; +import { UserRole } from '../common/enums'; +import { AuthenticatedUser } from '../common/decorators/current-user.decorator'; + +describe('UsersController', () => { + let controller: UsersController; + let usersService: { + list: jest.Mock; + findById: jest.Mock; + setStellarAddress: jest.Mock; + }; + + const mockPublicUser: PublicUserDto = { + id: 'user-1', + username: 'alice', + displayName: 'Alice Cooper', + avatarUrl: 'https://avatar.com/1', + roles: [UserRole.CONTRIBUTOR], + stellarAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + createdAt: new Date(), + }; + + beforeEach(async () => { + usersService = { + list: jest.fn().mockResolvedValue([mockPublicUser]), + findById: jest.fn().mockResolvedValue(mockPublicUser), + setStellarAddress: jest.fn().mockResolvedValue({ + ...mockPublicUser, + stellarAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [{ provide: UsersService, useValue: usersService }], + }).compile(); + + controller = module.get(UsersController); + }); + + it('lists public users', async () => { + const result = await controller.list(); + expect(result).toEqual([mockPublicUser]); + expect(usersService.list).toHaveBeenCalled(); + }); + + it('finds a user by id and returns public DTO shape', async () => { + const result = await controller.findOne('user-1'); + expect(result).toEqual(mockPublicUser); + expect(usersService.findById).toHaveBeenCalledWith('user-1'); + }); + + it('allows a user to update their own stellar address', async () => { + const caller: AuthenticatedUser = { + userId: 'user-1', + username: 'alice', + roles: [UserRole.CONTRIBUTOR], + }; + + const result = await controller.setStellarAddress( + 'user-1', + { stellarAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF' }, + caller, + ); + + expect(result.stellarAddress).toBe( + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', + ); + expect(usersService.setStellarAddress).toHaveBeenCalledWith( + 'user-1', + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF', + ); + }); + + it('throws 403 ForbiddenException when trying to patch another user address', async () => { + const caller: AuthenticatedUser = { + userId: 'user-2', + username: 'bob', + roles: [UserRole.CONTRIBUTOR], + }; + + expect(() => + controller.setStellarAddress( + 'user-1', + { stellarAddress: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF' }, + caller, + ), + ).toThrow(ForbiddenException); + + expect(usersService.setStellarAddress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 21559de8..8710259c 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + ForbiddenException, Get, Param, ParseUUIDPipe, @@ -10,6 +11,8 @@ import { import { ApiBearerAuth, ApiProperty, ApiTags } from '@nestjs/swagger'; import { UsersService } from './users.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'; import { IsStellarAddress } from '../common/validators/stellar-address.validator'; import { ApiInternalErrorResponse } from '../common/swagger/api-common-responses.decorator'; @@ -48,7 +51,13 @@ export class UsersController { setStellarAddress( @Param('id', new ParseUUIDPipe()) id: string, @Body() dto: SetStellarAddressDto, + @CurrentUser() user: AuthenticatedUser, ) { + if (user && user.userId !== id) { + throw new ForbiddenException( + 'You may only update your own Stellar address', + ); + } return this.usersService.setStellarAddress(id, dto.stellarAddress); } }