diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 0e58459..e7e6363 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -33,7 +33,6 @@ pure chore/docs commits). Direct pushes to main must also be logged here. - Added idempotent migration `20260826130000_ensure_users_unique_constraints.sql` to ensure unique indexes exist on `users.wallet_address` and `users.username`. - Updated `UsersRepository.createProfile()` to catch PostgreSQL unique constraint violation error `23505` and map to structured 409 `ConflictException` (`AUTH_WALLET_EXISTS`, `AUTH_USERNAME_TAKEN`). - Added cleanup handlers (`deleteAvatar`, `deleteUserById`) in `AuthService.register()` and `UsersRepository` to ensure failed registrations do not leave orphaned avatar files or partial user records. -- Added unit tests covering DB unique constraint error mapping, parallel race conditions for duplicate wallet and username registrations, sequential re-registration compatibility, and avatar/user cleanup on failure. ## 2026-07-23 diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index 42ad729..f685c5f 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -4,8 +4,10 @@ import { ExecutionContext, ForbiddenException, SetMetadata, + Optional, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { UserStatusService } from '../../modules/auth/user-status.service'; export const ROLES_KEY = 'roles'; @@ -19,18 +21,22 @@ export const ROLES_KEY = 'roles'; export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); /** - * Enforces the role claim carried in the JWT (set by JwtStrategy.validate). + * Enforces user role authorization based on server truth in the datastore + * (resolved via short-TTL cached UserStatusService). * * - Routes without @Roles metadata are unaffected. - * - Tokens without a role claim (role not chosen yet, or token issued - * before the role was set) are rejected with 403; the client must call - * POST /auth/refresh after setting a role to obtain the claim. + * - The JWT role claim is treated as a hint only; live datastore role is enforced. + * - Roles revoked or changed server-side take effect within USER_STATUS_CACHE_TTL_MS (30s) + * or immediately upon cache invalidation. */ @Injectable() export class RolesGuard implements CanActivate { - constructor(private readonly reflector: Reflector) {} + constructor( + private readonly reflector: Reflector, + @Optional() private readonly userStatusService?: UserStatusService, + ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), @@ -41,7 +47,21 @@ export class RolesGuard implements CanActivate { .switchToHttp() .getRequest<{ user?: { wallet: string; role?: string | null } }>(); - if (!user?.role || !requiredRoles.includes(user.role)) { + if (!user) { + throw new ForbiddenException({ + code: 'AUTH_ROLE_FORBIDDEN', + message: `This action requires one of the following roles: ${requiredRoles.join(', ')}.`, + }); + } + + let currentRole: string | null = null; + if (this.userStatusService && user.wallet) { + currentRole = await this.userStatusService.getRole(user.wallet); + } else { + currentRole = user.role ?? null; + } + + if (!currentRole || !requiredRoles.includes(currentRole)) { throw new ForbiddenException({ code: 'AUTH_ROLE_FORBIDDEN', message: `This action requires one of the following roles: ${requiredRoles.join(', ')}. If you just selected your role, refresh your access token.`, @@ -50,3 +70,5 @@ export class RolesGuard implements CanActivate { return true; } } + + diff --git a/src/database/repositories/users.repository.ts b/src/database/repositories/users.repository.ts index f877b0e..0c147f7 100644 --- a/src/database/repositories/users.repository.ts +++ b/src/database/repositories/users.repository.ts @@ -226,6 +226,31 @@ export class UsersRepository { return (data as { wallet_address: string; role: UserRole } | null) ?? null; } + /** + * Admin override: sets or resets the user's role regardless of whether a role was set previously. + * Returns the updated user row, or null if the user does not exist. + */ + async forceSetRole( + wallet: string, + role: UserRole | null, + ): Promise<{ wallet_address: string; role: UserRole | null } | null> { + const { data, error } = await this.supabaseService + .getServiceRoleClient() + .from('users') + .update({ role }) + .eq('wallet_address', wallet) + .select('wallet_address, role') + .maybeSingle(); + + if (error) { + throw new InternalServerErrorException({ + code: 'DATABASE_ROLE_UPDATE_FAILED', + message: 'Failed to update user role.', + }); + } + return (data as { wallet_address: string; role: UserRole | null } | null) ?? null; + } + // --- REGISTRATION METHODS --- async checkUsernameExists(username: string): Promise { diff --git a/src/modules/admin/admin-roles.controller.ts b/src/modules/admin/admin-roles.controller.ts new file mode 100644 index 0000000..da3a63d --- /dev/null +++ b/src/modules/admin/admin-roles.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Post, + Param, + Body, + NotFoundException, + UseGuards, + UseInterceptors, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AdminGuard } from '../../auth/guards/admin.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { AuditAction } from '../../common/decorators/audit-action.decorator'; +import { UsersRepository } from '../../database/repositories/users.repository'; +import { UserStatusService } from '../auth/user-status.service'; +import { AdminResetRoleDto } from './dto/admin-reset-role.dto'; + +@ApiTags('admin') +@Controller('admin') +@UseGuards(JwtAuthGuard, AdminGuard) +@ApiBearerAuth() +@UseInterceptors(AuditInterceptor) +export class AdminRolesController { + constructor( + private readonly usersRepository: UsersRepository, + private readonly userStatusService: UserStatusService, + ) {} + + @Post('users/:wallet/role/reset') + @HttpCode(HttpStatus.OK) + @AuditAction('admin_users', 'RESET_USER_ROLE') + @ApiOperation({ + summary: 'Reset or override user role (Admin only)', + description: + 'Allows admins to reset a user\'s permanent role back to null or override it with a specific role. ' + + 'Immediately invalidates the user\'s server-side status cache and logs an audit event.', + }) + @ApiParam({ name: 'wallet', description: 'Target user wallet address' }) + @ApiResponse({ status: 200, description: 'Role reset/updated successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden — wallet is not in ADMIN_WALLETS' }) + @ApiResponse({ status: 404, description: 'User not found' }) + async resetUserRole( + @Param('wallet') wallet: string, + @Body() dto?: AdminResetRoleDto, + ) { + const targetRole = dto?.role ?? null; + const updated = await this.usersRepository.forceSetRole(wallet, targetRole); + + if (!updated) { + throw new NotFoundException({ + code: 'USERS_NOT_FOUND', + message: 'User not found.', + }); + } + + this.userStatusService.invalidate(wallet); + + return { + success: true, + data: { + wallet: updated.wallet_address, + role: updated.role, + }, + message: 'User role updated successfully.', + }; + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 566323e..0b15969 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -1,11 +1,15 @@ import { Module } from '@nestjs/common'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; +import { AdminRolesController } from './admin-roles.controller'; import { SupabaseService } from '../../database/supabase.client'; +import { UsersRepository } from '../../database/repositories/users.repository'; +import { UserStatusService } from '../auth/user-status.service'; +import { AdminGuard } from '../../auth/guards/admin.guard'; @Module({ - controllers: [AuditController], - providers: [AuditService, SupabaseService], - exports: [AuditService], + controllers: [AuditController, AdminRolesController], + providers: [AuditService, SupabaseService, UsersRepository, UserStatusService, AdminGuard], + exports: [AuditService, UserStatusService], }) export class AdminModule {} diff --git a/src/modules/admin/dto/admin-reset-role.dto.ts b/src/modules/admin/dto/admin-reset-role.dto.ts new file mode 100644 index 0000000..df6f1f9 --- /dev/null +++ b/src/modules/admin/dto/admin-reset-role.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; +import { UserRole } from '../../../database/repositories/users.repository'; + +export class AdminResetRoleDto { + @ApiPropertyOptional({ + description: 'New role to assign (sponsor | vendor | mentor), or null to reset/remove the role', + enum: ['sponsor', 'vendor', 'mentor'], + nullable: true, + }) + @IsOptional() + @IsIn(['sponsor', 'vendor', 'mentor', null]) + role?: UserRole | null; +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 2b9bb42..b7fe851 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -12,6 +12,8 @@ import { UsersRepository } from '../../database/repositories/users.repository'; import { getJwtConfig } from '../../config/jwt.config'; import { AdminModule } from '../admin/admin.module'; +import { RolesGuard } from '../../auth/guards/roles.guard'; + @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), @@ -23,7 +25,7 @@ import { AdminModule } from '../admin/admin.module'; AdminModule, ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], - exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, PassportModule], + providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, RolesGuard, SupabaseService, ConfigService, UsersRepository], + exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, RolesGuard, PassportModule], }) export class AuthModule {} diff --git a/src/modules/auth/user-status.service.ts b/src/modules/auth/user-status.service.ts index a97b9f6..5d39de4 100644 --- a/src/modules/auth/user-status.service.ts +++ b/src/modules/auth/user-status.service.ts @@ -2,67 +2,79 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { SupabaseService } from '../../database/supabase.client'; /** - * How long a user's status may be served from cache before re-checking the - * database. This is the documented staleness bound for blocking enforcement: - * a blocked wallet can keep using valid access tokens for AT MOST this many - * seconds (plus the remaining lifetime of its current access token is NOT - * granted — requests within this window are the only grace period). + * How long a user's status and role may be served from cache before re-checking + * the database. This is the documented staleness bound for server-truth enforcement: + * role changes or blocked wallets take effect within AT MOST this many seconds + * (or immediately when invalidate() is called after role/status changes). */ export const USER_STATUS_CACHE_TTL_MS = 30_000; -interface CachedStatus { +interface CachedUserState { status: string; + role: string | null; expiresAt: number; } /** - * Short-TTL in-memory cache of user account status, consulted on every - * authenticated request by JwtStrategy so that blocked wallets lose API - * access within USER_STATUS_CACHE_TTL_MS instead of waiting for their - * access token to expire naturally. + * Short-TTL in-memory cache of user account status and role, consulted on every + * authenticated request by JwtStrategy and RolesGuard so that authorization decisions + * rely on server truth rather than un-enforced JWT claims. * - * A local in-memory Map is used deliberately instead of Redis: the check - * runs on every request, one Redis round trip per request would double - * auth latency, and a 30s staleness bound does not justify shared state. - * On multi-instance deployments each instance maintains its own cache with - * the same bound. + * A local in-memory Map is used deliberately instead of Redis: checks run on every + * request, one Redis round trip per request would double auth latency, and a 30s + * staleness bound does not justify shared state. On multi-instance deployments each + * instance maintains its own cache with the same bound. */ @Injectable() export class UserStatusService { private readonly logger = new Logger(UserStatusService.name); - private readonly cache = new Map(); + private readonly cache = new Map(); constructor(private readonly supabaseService: SupabaseService) {} /** - * Returns the user's status ('active', 'blocked', ...), serving from the - * cache when fresh. Never throws for DB errors — fails open so a database - * blip cannot lock out every authenticated user; the failure is logged. + * Returns the user's current status and role, serving from cache when fresh. + * Never throws for DB errors — fails open so a database blip cannot lock out + * every authenticated user; the failure is logged. */ - async getStatus(wallet: string): Promise { + async getUserState(wallet: string): Promise<{ status: string; role: string | null }> { const cached = this.cache.get(wallet); if (cached && cached.expiresAt > Date.now()) { - return cached.status; + return { status: cached.status, role: cached.role }; } let status = 'active'; + let role: string | null = null; try { const client = this.supabaseService.getServiceRoleClient(); const { data, error } = await client .from('users') - .select('status') + .select('status, role') .eq('wallet_address', wallet) .maybeSingle(); - if (!error && data?.status) { - status = data.status; + if (!error && data) { + if (data.status) status = data.status; + if (data.role !== undefined) role = data.role ?? null; } if (error) { - this.logger.error(`Failed to read status for ${wallet}: ${error.message}`); + this.logger.error(`Failed to read user state for ${wallet}: ${error.message}`); } } catch (err) { - this.logger.error(`User status lookup failed for ${wallet}`, err); + this.logger.error(`User state lookup failed for ${wallet}`, err); } - this.cache.set(wallet, { status, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS }); - return status; + this.cache.set(wallet, { status, role, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS }); + return { status, role }; + } + + /** Returns the user's status ('active', 'blocked', ...), serving from cache when fresh. */ + async getStatus(wallet: string): Promise { + const state = await this.getUserState(wallet); + return state.status; + } + + /** Returns the user's current role ('sponsor', 'vendor', 'mentor', null), serving from cache when fresh. */ + async getRole(wallet: string): Promise { + const state = await this.getUserState(wallet); + return state.role; } /** Throws AUTH_USER_BLOCKED when the wallet's account is suspended. */ @@ -73,8 +85,9 @@ export class UserStatusService { } } - /** Test/admin helper: drops cached status so the next check hits the DB. */ + /** Test/admin helper: drops cached state so the next check hits the DB. */ invalidate(wallet: string): void { this.cache.delete(wallet); } } + diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 07f193e..f5592ad 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -3,16 +3,15 @@ import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { UsersRepository } from '../../database/repositories/users.repository'; import { SupabaseService } from '../../database/supabase.client'; +import { AuthModule } from '../auth/auth.module'; /** * Users feature module. - * - * Note: JwtAuthGuard is NOT listed as a provider here — it lives in AuthModule - * (created in API-03) and is resolved from there by NestJS's DI container. */ @Module({ + imports: [AuthModule], controllers: [UsersController], providers: [UsersService, UsersRepository, SupabaseService], - exports: [UsersService], + exports: [UsersService, UsersRepository], }) export class UsersModule { } \ No newline at end of file diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 7894fde..c13eeab 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { UsersRepository, UserPreferencesRecord, UserRole } from '../../database/repositories/users.repository'; +import { UserStatusService } from '../auth/user-status.service'; import { UserProfileDto, UserPreferencesDto } from './dto/user-response.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserProfileDto } from './dto/user-profile.dto'; @@ -11,7 +12,10 @@ import { SetRoleResponseDto } from './dto/set-role.dto'; */ @Injectable() export class UsersService { - constructor(private readonly usersRepository: UsersRepository) { } + constructor( + private readonly usersRepository: UsersRepository, + private readonly userStatusService: UserStatusService, + ) { } /** * Retrieves the authenticated user's profile including preferences. @@ -77,6 +81,8 @@ export class UsersService { }); } + this.userStatusService.invalidate(wallet); + return { wallet: updated.wallet_address, role: updated.role }; } diff --git a/src/modules/vouching/vouching.module.ts b/src/modules/vouching/vouching.module.ts index 89d376c..4e86318 100644 --- a/src/modules/vouching/vouching.module.ts +++ b/src/modules/vouching/vouching.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { VouchingService } from './vouching.service'; import { VouchingController } from './vouching.controller'; import { SupabaseService } from '../../database/supabase.client'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], providers: [VouchingService, SupabaseService], controllers: [VouchingController], exports: [VouchingService], diff --git a/test/unit/modules/admin/admin-roles.controller.spec.ts b/test/unit/modules/admin/admin-roles.controller.spec.ts new file mode 100644 index 0000000..f8ee2f3 --- /dev/null +++ b/test/unit/modules/admin/admin-roles.controller.spec.ts @@ -0,0 +1,73 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { AdminRolesController } from '../../../../src/modules/admin/admin-roles.controller'; +import { UsersRepository } from '../../../../src/database/repositories/users.repository'; +import { UserStatusService } from '../../../../src/modules/auth/user-status.service'; +import { AuditInterceptor } from '../../../../src/common/interceptors/audit.interceptor'; +import { Reflector } from '@nestjs/core'; + +describe('AdminRolesController', () => { + let controller: AdminRolesController; + let usersRepository: UsersRepository; + let userStatusService: UserStatusService; + + const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + + const mockUsersRepository = { + forceSetRole: jest.fn(), + }; + + const mockUserStatusService = { + invalidate: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminRolesController], + providers: [ + Reflector, + AuditInterceptor, + { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: UserStatusService, useValue: mockUserStatusService }, + ], + }).compile(); + + controller = module.get(AdminRolesController); + usersRepository = module.get(UsersRepository); + userStatusService = module.get(UserStatusService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should reset user role to null and invalidate cache', async () => { + mockUsersRepository.forceSetRole.mockResolvedValue({ wallet_address: validWallet, role: null }); + + const result = await controller.resetUserRole(validWallet); + + expect(mockUsersRepository.forceSetRole).toHaveBeenCalledWith(validWallet, null); + expect(mockUserStatusService.invalidate).toHaveBeenCalledWith(validWallet); + expect(result.success).toBe(true); + expect(result.data.role).toBeNull(); + }); + + it('should update user role to specified role and invalidate cache', async () => { + mockUsersRepository.forceSetRole.mockResolvedValue({ wallet_address: validWallet, role: 'sponsor' }); + + const result = await controller.resetUserRole(validWallet, { role: 'sponsor' }); + + expect(mockUsersRepository.forceSetRole).toHaveBeenCalledWith(validWallet, 'sponsor'); + expect(mockUserStatusService.invalidate).toHaveBeenCalledWith(validWallet); + expect(result.data.role).toBe('sponsor'); + }); + + it('should throw NotFoundException (404 USERS_NOT_FOUND) when target user does not exist', async () => { + mockUsersRepository.forceSetRole.mockResolvedValue(null); + + await expect(controller.resetUserRole(validWallet)).rejects.toMatchObject({ + response: { code: 'USERS_NOT_FOUND' }, + }); + }); +}); diff --git a/test/unit/modules/auth/roles.guard.spec.ts b/test/unit/modules/auth/roles.guard.spec.ts new file mode 100644 index 0000000..a3c593c --- /dev/null +++ b/test/unit/modules/auth/roles.guard.spec.ts @@ -0,0 +1,97 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard, ROLES_KEY } from '../../../../src/auth/guards/roles.guard'; +import { UserStatusService } from '../../../../src/modules/auth/user-status.service'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: Reflector; + let userStatusService: UserStatusService; + + const mockUserStatusService = { + getRole: jest.fn(), + getUserState: jest.fn(), + invalidate: jest.fn(), + }; + + const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + + function createMockExecutionContext(userPayload?: { wallet: string; role?: string | null }, handlerRoles?: string[]): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ + user: userPayload, + }), + }), + } as unknown as ExecutionContext; + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RolesGuard, + Reflector, + { provide: UserStatusService, useValue: mockUserStatusService }, + ], + }).compile(); + + guard = module.get(RolesGuard); + reflector = module.get(Reflector); + userStatusService = module.get(UserStatusService); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('should allow access if no roles metadata is set on route', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + const context = createMockExecutionContext({ wallet: validWallet, role: 'vendor' }); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + expect(mockUserStatusService.getRole).not.toHaveBeenCalled(); + }); + + it('should allow access if datastore role matches required role', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['vendor']); + mockUserStatusService.getRole.mockResolvedValue('vendor'); + const context = createMockExecutionContext({ wallet: validWallet, role: 'vendor' }); + + const result = await guard.canActivate(context); + expect(result).toBe(true); + expect(mockUserStatusService.getRole).toHaveBeenCalledWith(validWallet); + }); + + it('should reject access (403 AUTH_ROLE_FORBIDDEN) if JWT has role but datastore role is null/different (stale token attack)', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['vendor']); + // Datastore role was reset to null by admin + mockUserStatusService.getRole.mockResolvedValue(null); + // User presents old JWT with role: 'vendor' + const context = createMockExecutionContext({ wallet: validWallet, role: 'vendor' }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + await expect(guard.canActivate(context)).rejects.toMatchObject({ + response: { code: 'AUTH_ROLE_FORBIDDEN' }, + }); + }); + + it('should reject access (403 AUTH_ROLE_FORBIDDEN) if user role was downgraded in datastore', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['sponsor']); + mockUserStatusService.getRole.mockResolvedValue('vendor'); // Changed in DB + const context = createMockExecutionContext({ wallet: validWallet, role: 'sponsor' }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('should reject access (403 AUTH_ROLE_FORBIDDEN) if req.user is missing', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['vendor']); + const context = createMockExecutionContext(undefined); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/test/unit/modules/users/users.service.spec.ts b/test/unit/modules/users/users.service.spec.ts index b28c6ec..0be1eac 100644 --- a/test/unit/modules/users/users.service.spec.ts +++ b/test/unit/modules/users/users.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from '../../../../src/modules/users/users.service'; import { UsersRepository } from '../../../../src/database/repositories/users.repository'; +import { UserStatusService } from '../../../../src/modules/auth/user-status.service'; import { UpdateUserDto } from '../../../../src/modules/users/dto/update-user.dto'; describe('UsersService', () => { @@ -38,6 +39,12 @@ describe('UsersService', () => { create: jest.fn(), createDefaultPreferences: jest.fn(), update: jest.fn(), + setRoleIfUnset: jest.fn(), + findRoleByWallet: jest.fn(), + }; + + const mockUserStatusService = { + invalidate: jest.fn(), }; beforeEach(async () => { @@ -48,6 +55,10 @@ describe('UsersService', () => { provide: UsersRepository, useValue: mockUsersRepository, }, + { + provide: UserStatusService, + useValue: mockUserStatusService, + }, ], }).compile(); @@ -301,4 +312,40 @@ describe('UsersService', () => { }); }); }); + + // --------------------------------------------------------------------------- + // setRole + // --------------------------------------------------------------------------- + describe('setRole', () => { + const wallet = 'GABC123XYZ456DEF789ABCDEF0123456789ABCDEF0123456789ABCDEFGHIJ'; + + it('should set role and invalidate status cache when role is unset', async () => { + mockUsersRepository.setRoleIfUnset.mockResolvedValue({ wallet_address: wallet, role: 'vendor' }); + + const result = await service.setRole(wallet, 'vendor'); + + expect(mockUsersRepository.setRoleIfUnset).toHaveBeenCalledWith(wallet, 'vendor'); + expect(mockUserStatusService.invalidate).toHaveBeenCalledWith(wallet); + expect(result).toEqual({ wallet, role: 'vendor' }); + }); + + it('should throw ConflictException (USERS_ROLE_ALREADY_SET) when role is already set', async () => { + mockUsersRepository.setRoleIfUnset.mockResolvedValue(null); + mockUsersRepository.findRoleByWallet.mockResolvedValue('sponsor'); + + await expect(service.setRole(wallet, 'vendor')).rejects.toMatchObject({ + response: { code: 'USERS_ROLE_ALREADY_SET' }, + }); + expect(mockUserStatusService.invalidate).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException (USERS_NOT_FOUND) when user does not exist', async () => { + mockUsersRepository.setRoleIfUnset.mockResolvedValue(null); + mockUsersRepository.findRoleByWallet.mockResolvedValue(null); + + await expect(service.setRole(wallet, 'vendor')).rejects.toMatchObject({ + response: { code: 'USERS_NOT_FOUND' }, + }); + }); + }); }); diff --git a/test/unit/modules/vendors/vendors.service.spec.ts b/test/unit/modules/vendors/vendors.service.spec.ts index b3320e0..fd802bc 100644 --- a/test/unit/modules/vendors/vendors.service.spec.ts +++ b/test/unit/modules/vendors/vendors.service.spec.ts @@ -8,6 +8,8 @@ import { VendorRegistryContractClient } from '../../../../src/stellar/contracts/ import { VendorType, VendorStatus } from '../../../../src/modules/vendors/dto/vendor.dto'; import { VendorsRepository } from '../../../../src/database/repositories/vendors.repository'; import { AdminGuard } from '../../../../src/common/guards/admin.guard'; +import { RolesGuard } from '../../../../src/auth/guards/roles.guard'; +import { UserStatusService } from '../../../../src/modules/auth/user-status.service'; describe('VendorsModule', () => { let service: VendorsService; @@ -80,6 +82,8 @@ describe('VendorsModule', () => { providers: [ VendorsService, AdminGuard, + RolesGuard, + { provide: UserStatusService, useValue: { getRole: jest.fn().mockResolvedValue('vendor') } }, { provide: SupabaseService, useValue: mockSupabaseService }, { provide: VendorsRepository, useValue: { findByWallet: jest.fn() } }, { provide: VendorRegistryContractClient, useValue: mockVendorRegistryClient },