Skip to content
Open
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
86 changes: 86 additions & 0 deletions src/users/user-response.mapper.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});
82 changes: 82 additions & 0 deletions src/users/user-response.mapper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
94 changes: 94 additions & 0 deletions src/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
52 changes: 42 additions & 10 deletions src/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -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<PublicUser[]> {
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<PublicUser> {
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<PublicUser> {
const userPayload = req.user as AuthenticatedUserPayload | undefined;
const user = await this.usersService.setStellarAddress(
id,
dto.stellarAddress,
);
return toPublicUser(user, { currentUser: userPayload });
}
}