diff --git a/src/users/user-response.mapper.spec.ts b/src/users/user-response.mapper.spec.ts new file mode 100644 index 0000000..281b01e --- /dev/null +++ b/src/users/user-response.mapper.spec.ts @@ -0,0 +1,86 @@ +import { toPublicUser } from './user-response.mapper'; +import { User, GithubAccount } from '../common/entities'; +import { UserRole } from '../common/enums'; + +function makeTestUser(overrides: Partial = {}): User { + const user = new User(); + user.id = 'user-uuid-1'; + user.username = 'alice'; + user.email = 'alice@example.com'; + user.displayName = 'Alice Developer'; + user.avatarUrl = 'https://avatars.githubusercontent.com/u/123'; + user.roles = [UserRole.CONTRIBUTOR]; + user.stellarAddress = 'GA2C5RFPE6GCKMY3Z4AWSDVOMLCUCPW7S55QHGQ7G6EGE2TRD4F4QG'; + user.createdAt = new Date('2026-01-01T00:00:00Z'); + user.updatedAt = new Date('2026-01-02T00:00:00Z'); + user.githubAccount = null; + return Object.assign(user, overrides); +} + +describe('UserResponseMapper (toPublicUser)', () => { + it('strips email for an unauthenticated or unrelated user request', () => { + const user = makeTestUser(); + const result = toPublicUser(user, { + currentUser: { userId: 'different-user-id', roles: [UserRole.CONTRIBUTOR] }, + }); + + expect(result).not.toHaveProperty('email'); + expect(result.id).toBe('user-uuid-1'); + expect(result.username).toBe('alice'); + expect(result.displayName).toBe('Alice Developer'); + expect(result.stellarAddress).toBe('GA2C5RFPE6GCKMY3Z4AWSDVOMLCUCPW7S55QHGQ7G6EGE2TRD4F4QG'); + }); + + it('strips email when no currentUser is provided', () => { + const user = makeTestUser(); + const result = toPublicUser(user); + + expect(result).not.toHaveProperty('email'); + expect(result.username).toBe('alice'); + }); + + it('includes email when caller is the owner (same userId)', () => { + const user = makeTestUser(); + const result = toPublicUser(user, { + currentUser: { userId: 'user-uuid-1', roles: [UserRole.CONTRIBUTOR] }, + }); + + expect(result.email).toBe('alice@example.com'); + }); + + it('includes email when caller is an admin or maintainer', () => { + const user = makeTestUser(); + const result = toPublicUser(user, { + currentUser: { userId: 'admin-user-id', roles: ['admin'] }, + }); + + expect(result.email).toBe('alice@example.com'); + + const resultMaintainer = toPublicUser(user, { + currentUser: { userId: 'm-user-id', roles: [UserRole.MAINTAINER] }, + }); + + expect(resultMaintainer.email).toBe('alice@example.com'); + }); + + it('safely serializes githubAccount relation without sensitive token properties', () => { + const account = new GithubAccount(); + account.id = 'gh-acc-1'; + account.githubId = '12345'; + account.login = 'alice'; + account.profileUrl = 'https://github.com/alice'; + account.avatarUrl = 'https://avatars.githubusercontent.com/u/123'; + account.accessToken = 'gho_secret_token_12345'; + account.refreshToken = 'ghr_refresh_token_12345'; + account.createdAt = new Date('2026-01-01T00:00:00Z'); + account.updatedAt = new Date('2026-01-02T00:00:00Z'); + + const user = makeTestUser({ githubAccount: account }); + const result = toPublicUser(user); + + expect(result.githubAccount).toBeDefined(); + expect(result.githubAccount?.login).toBe('alice'); + expect(result.githubAccount).not.toHaveProperty('accessToken'); + expect(result.githubAccount).not.toHaveProperty('refreshToken'); + }); +}); diff --git a/src/users/user-response.mapper.ts b/src/users/user-response.mapper.ts new file mode 100644 index 0000000..5ddcf8b --- /dev/null +++ b/src/users/user-response.mapper.ts @@ -0,0 +1,82 @@ +import { User } from '../common/entities'; +import { UserRole } from '../common/enums'; + +export interface PublicGithubAccount { + id: string; + githubId: string; + login: string; + profileUrl: string | null; + avatarUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface PublicUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + roles: UserRole[]; + stellarAddress: string | null; + email?: string | null; + githubAccount?: PublicGithubAccount | null; + createdAt: Date; + updatedAt: Date; +} + +export interface UserSerializationOptions { + /** + * The authenticated user performing the request (if any). + */ + currentUser?: { + userId?: string; + roles?: (UserRole | string)[]; + } | null; +} + +/** + * Maps a User entity into a public-safe HTTP response DTO. + * Strips sensitive PII (`email`) unless the request caller is the user themselves + * or has an admin/maintainer role (or 'admin' role string), mirroring the pattern established in `toPublicEscrow`. + */ +export function toPublicUser( + user: User, + options?: UserSerializationOptions, +): PublicUser { + const isOwner = options?.currentUser?.userId === user.id; + const isAdmin = + options?.currentUser?.roles?.includes('admin') || + options?.currentUser?.roles?.includes(UserRole.MAINTAINER) || + false; + const canViewEmail = isOwner || isAdmin; + + const publicGithubAccount: PublicGithubAccount | null = user.githubAccount + ? { + id: user.githubAccount.id, + githubId: user.githubAccount.githubId, + login: user.githubAccount.login, + profileUrl: user.githubAccount.profileUrl, + avatarUrl: user.githubAccount.avatarUrl, + createdAt: user.githubAccount.createdAt, + updatedAt: user.githubAccount.updatedAt, + } + : null; + + const publicUser: PublicUser = { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + roles: user.roles, + stellarAddress: user.stellarAddress, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + ...(user.githubAccount !== undefined ? { githubAccount: publicGithubAccount } : {}), + }; + + if (canViewEmail) { + publicUser.email = user.email; + } + + return publicUser; +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 0000000..ea1a2a9 --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { User } from '../common/entities'; +import { UserRole } from '../common/enums'; +import type { Request } from 'express'; + +function makeUser(id: string, email: string, username: string): User { + const user = new User(); + user.id = id; + user.email = email; + user.username = username; + user.displayName = username.toUpperCase(); + user.avatarUrl = null; + user.roles = [UserRole.CONTRIBUTOR]; + user.stellarAddress = 'GSTELLARADDRESS'; + user.createdAt = new Date('2026-01-01'); + user.updatedAt = new Date('2026-01-02'); + user.githubAccount = null; + return user; +} + +describe('UsersController (Auth & PII Protection #64)', () => { + let controller: UsersController; + let usersService: { + list: jest.Mock; + findById: jest.Mock; + setStellarAddress: jest.Mock; + }; + + const user1 = makeUser('user-1', 'alice@example.com', 'alice'); + const user2 = makeUser('user-2', 'bob@example.com', 'bob'); + + beforeEach(async () => { + usersService = { + list: jest.fn().mockResolvedValue([user1, user2]), + findById: jest.fn().mockImplementation((id: string) => { + if (id === 'user-1') return Promise.resolve(user1); + if (id === 'user-2') return Promise.resolve(user2); + return Promise.reject(new Error('User not found')); + }), + setStellarAddress: jest.fn().mockResolvedValue(user1), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [{ provide: UsersService, useValue: usersService }], + }).compile(); + + controller = module.get(UsersController); + }); + + describe('GET /users (list)', () => { + it('redacts email for users other than the caller', async () => { + const req = { + user: { userId: 'user-1', username: 'alice', roles: [UserRole.CONTRIBUTOR] }, + } as unknown as Request; + + const result = await controller.list(req); + + expect(result).toHaveLength(2); + // Own user keeps email + expect(result[0].id).toBe('user-1'); + expect(result[0].email).toBe('alice@example.com'); + + // Other user has email redacted + expect(result[1].id).toBe('user-2'); + expect(result[1]).not.toHaveProperty('email'); + }); + }); + + describe('GET /users/:id (findOne)', () => { + it('returns email when fetching own profile', async () => { + const req = { + user: { userId: 'user-1', username: 'alice', roles: [UserRole.CONTRIBUTOR] }, + } as unknown as Request; + + const result = await controller.findOne('user-1', req); + expect(result.id).toBe('user-1'); + expect(result.email).toBe('alice@example.com'); + }); + + it('redacts email when fetching another user profile', async () => { + const req = { + user: { userId: 'user-1', username: 'alice', roles: [UserRole.CONTRIBUTOR] }, + } as unknown as Request; + + const result = await controller.findOne('user-2', req); + expect(result.id).toBe('user-2'); + expect(result).not.toHaveProperty('email'); + expect(result.username).toBe('bob'); + }); + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 37a22bb..923c6fc 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,36 +1,68 @@ -import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { IsString } from 'class-validator'; +import type { Request } from 'express'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { toPublicUser, PublicUser } from './user-response.mapper'; +import { UserRole } from '../common/enums'; class SetStellarAddressDto { @IsString() stellarAddress: string; } +interface AuthenticatedUserPayload { + userId: string; + username: string; + roles?: UserRole[]; +} + @ApiTags('users') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() - list() { - return this.usersService.list(); + async list(@Req() req: Request): Promise { + const userPayload = req.user as AuthenticatedUserPayload | undefined; + const users = await this.usersService.list(); + return users.map((user) => + toPublicUser(user, { currentUser: userPayload }), + ); } @Get(':id') - findOne(@Param('id') id: string) { - return this.usersService.findById(id); + async findOne( + @Param('id') id: string, + @Req() req: Request, + ): Promise { + const userPayload = req.user as AuthenticatedUserPayload | undefined; + const user = await this.usersService.findById(id); + return toPublicUser(user, { currentUser: userPayload }); } - @ApiBearerAuth() - @UseGuards(JwtAuthGuard) @Patch(':id/stellar-address') - setStellarAddress( + async setStellarAddress( @Param('id') id: string, @Body() dto: SetStellarAddressDto, - ) { - return this.usersService.setStellarAddress(id, dto.stellarAddress); + @Req() req: Request, + ): Promise { + const userPayload = req.user as AuthenticatedUserPayload | undefined; + const user = await this.usersService.setStellarAddress( + id, + dto.stellarAddress, + ); + return toPublicUser(user, { currentUser: userPayload }); } }