prisma with psotgresql

This commit is contained in:
2025-11-23 23:53:13 +08:00
parent d88c2057c0
commit 978158353c
25 changed files with 1718 additions and 407 deletions

View File

@@ -2,19 +2,8 @@
PORT=3000
NODE_ENV=development
# Keycloak OpenID Connect Configuration
# The base URL of your Keycloak server (e.g., https://keycloak.example.com)
KEYCLOAK_AUTH_SERVER_URL=https://your-keycloak-instance.com/auth
# The Keycloak realm name
KEYCLOAK_REALM=your-realm-name
# The client ID registered in Keycloak for this application
KEYCLOAK_CLIENT_ID=friendolls-api
# The client secret (required if the client is confidential)
# Leave empty if using a public client
KEYCLOAK_CLIENT_SECRET=
# Database connection string
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public"
# JWT Configuration
# The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM})

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ pids
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/generated/prisma

219
README.md
View File

@@ -1 +1,218 @@
Friendolls. Backend edition.
# Friendolls Server
Backend API for the Friendolls application built with NestJS, Keycloak authentication, and Prisma ORM.
## Tech Stack
- **Framework:** [NestJS](https://nestjs.com/) - Progressive Node.js framework
- **Authentication:** [Keycloak](https://www.keycloak.org/) - OpenID Connect (OIDC) / OAuth 2.0
- **Database ORM:** [Prisma](https://www.prisma.io/) - Next-generation TypeScript ORM
- **Database:** PostgreSQL
- **Language:** TypeScript
- **Package Manager:** pnpm
## Features
- ✅ Keycloak OIDC authentication with JWT tokens
- ✅ User management synchronized from Keycloak
- ✅ PostgreSQL database with Prisma ORM
- ✅ Swagger API documentation
- ✅ Role-based access control
- ✅ Global exception handling
- ✅ Environment-based configuration
- ✅ Comprehensive logging
## Prerequisites
- Node.js 18+
- pnpm
- PostgreSQL 14+ (or Docker)
- Keycloak instance (for authentication)
## Getting Started
### 1. Install Dependencies
```bash
pnpm install
```
### 2. Set Up Environment Variables
Create a `.env` file in the root directory:
```env
# Server Configuration
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public"
# Keycloak OIDC Configuration
JWKS_URI=https://your-keycloak-instance.com/realms/your-realm/protocol/openid-connect/certs
JWT_ISSUER=https://your-keycloak-instance.com/realms/your-realm
JWT_AUDIENCE=your-client-id
```
### 3. Set Up PostgreSQL Database
**Option A: Using Docker (Recommended for development)**
```bash
docker run --name friendolls-postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=friendolls_dev \
-p 5432:5432 \
-d postgres:16-alpine
```
**Option B: Use existing PostgreSQL installation**
```bash
createdb friendolls_dev
```
See [docs/PRISMA_SETUP.md](docs/PRISMA_SETUP.md) for detailed database setup instructions.
### 4. Generate Prisma Client and Run Migrations
```bash
# Generate Prisma Client (creates TypeScript types)
pnpm prisma:generate
# Run migrations to create database schema
pnpm prisma:migrate
# If migration already exists, it will say "Already in sync"
```
**Important:** Always run `pnpm prisma:generate` after pulling new code or changing the Prisma schema.
### 5. Start the Development Server
```bash
pnpm start:dev
```
The server will be running at `http://localhost:3000`.
## Available Scripts
### Development
- `pnpm start:dev` - Start development server with hot reload
- `pnpm start:debug` - Start development server with debugging
### Production
- `pnpm build` - Build the application
- `pnpm start:prod` - Start production server
### Database (Prisma)
- `pnpm prisma:generate` - Generate Prisma Client
- `pnpm prisma:migrate` - Create and apply database migration
- `pnpm prisma:migrate:deploy` - Apply migrations in production
- `pnpm prisma:studio` - Open Prisma Studio (visual database browser)
- `pnpm db:push` - Push schema changes (dev only)
- `pnpm db:reset` - Reset database and reapply migrations
### Code Quality
- `pnpm lint` - Lint the code
- `pnpm format` - Format code with Prettier
### Testing
- `pnpm test` - Run unit tests
- `pnpm test:watch` - Run tests in watch mode
- `pnpm test:cov` - Run tests with coverage
- `pnpm test:e2e` - Run end-to-end tests
## API Documentation
Once the server is running, access the Swagger API documentation at:
```
http://localhost:3000/api
```
## Project Structure
```
friendolls-server/
├── prisma/ # Prisma configuration
│ ├── migrations/ # Database migrations
│ └── schema.prisma # Database schema
├── src/
│ ├── auth/ # Authentication module (Keycloak OIDC)
│ ├── common/ # Shared utilities and decorators
│ ├── config/ # Configuration
│ ├── database/ # Database module (Prisma)
│ │ ├── prisma.service.ts
│ │ └── database.module.ts
│ ├── users/ # Users module
│ │ ├── dto/ # Data Transfer Objects
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── users.entity.ts
│ ├── app.module.ts # Root application module
│ └── main.ts # Application entry point
├── test/ # E2E tests
├── .env # Environment variables (gitignored)
├── package.json
└── tsconfig.json
```
## Database Schema
The application uses Prisma with PostgreSQL. The main entity is:
### User Model
- Synchronized from Keycloak OIDC
- Stores user profile information
- Tracks login history
- Manages user roles
## Authentication Flow
1. User authenticates via Keycloak
2. Keycloak issues JWT token
3. Client sends JWT in `Authorization: Bearer <token>` header
4. Server validates JWT against Keycloak's JWKS
5. User is automatically created/synced from token on first login
6. Subsequent requests update user's last login time
## Development Workflow
1. **Make schema changes** in `prisma/schema.prisma`
2. **Create migration**: `pnpm prisma:migrate`
3. **Implement business logic** in services
4. **Create/update DTOs** for request validation
5. **Update controllers** for API endpoints
6. **Test** your changes
7. **Lint and format**: `pnpm lint && pnpm format`
8. **Commit** your changes
## Environment Variables
| Variable | Description | Required | Example |
| -------------- | ---------------------------- | ------------------ | ------------------------------------------------------------------- |
| `PORT` | Server port | No (default: 3000) | `3000` |
| `NODE_ENV` | Environment | No | `development`, `production` |
| `DATABASE_URL` | PostgreSQL connection string | **Yes** | `postgresql://user:pass@host:5432/db` |
| `JWKS_URI` | Keycloak JWKS endpoint | **Yes** | `https://keycloak.com/realms/myrealm/protocol/openid-connect/certs` |
| `JWT_ISSUER` | JWT issuer (Keycloak realm) | **Yes** | `https://keycloak.com/realms/myrealm` |
| `JWT_AUDIENCE` | JWT audience (client ID) | **Yes** | `my-client-id` |
## Production Deployment
1. **Set environment variables** (especially `DATABASE_URL`)
2. **Install dependencies**: `pnpm install --prod`
3. **Generate Prisma Client**: `pnpm prisma:generate`
4. **Run migrations**: `pnpm prisma:migrate:deploy`
5. **Build application**: `pnpm build`
6. **Start server**: `pnpm start:prod`

View File

@@ -29,6 +29,7 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-return': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
@@ -38,6 +39,7 @@ export default tseslint.config(
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
},
},
);

View File

@@ -17,7 +17,14 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:studio": "prisma studio",
"prisma:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset",
"db:push": "prisma db push"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@@ -26,12 +33,15 @@
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.3",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"jwks-rsa": "^3.2.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -44,6 +54,8 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -51,6 +63,7 @@
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^7.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",

839
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,6 @@
onlyBuiltDependencies:
- '@nestjs/core'
- '@prisma/engines'
- '@scarf/scarf'
- prisma
- unrs-resolver

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"keycloak_sub" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT,
"picture" TEXT,
"roles" TEXT[],
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_keycloak_sub_key" ON "users"("keycloak_sub");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

47
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,47 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client"
}
datasource db {
provider = "postgresql"
}
/// User model representing authenticated users from Keycloak OIDC
model User {
/// Internal unique identifier (UUID)
id String @id @default(uuid())
/// Keycloak subject identifier (unique per user in Keycloak)
/// This is the 'sub' claim from the JWT token
keycloakSub String @unique @map("keycloak_sub")
/// User's display name
name String
/// User's email address
email String
/// User's preferred username from Keycloak
username String?
/// URL to user's profile picture
picture String?
/// User's roles from Keycloak (stored as JSON array)
roles String[]
/// Timestamp when the user was first created in the system
createdAt DateTime @default(now()) @map("created_at")
/// Timestamp when the user profile was last updated
updatedAt DateTime @updatedAt @map("updated_at")
/// Timestamp of last login
lastLoginAt DateTime? @map("last_login_at")
@@map("users")
}

View File

@@ -4,6 +4,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { DatabaseModule } from './database/database.module';
/**
* Validates required environment variables.
@@ -11,7 +12,12 @@ import { AuthModule } from './auth/auth.module';
* Returns the validated config.
*/
function validateEnvironment(config: Record<string, any>): Record<string, any> {
const requiredVars = ['JWKS_URI', 'JWT_ISSUER', 'JWT_AUDIENCE'];
const requiredVars = [
'JWKS_URI',
'JWT_ISSUER',
'JWT_AUDIENCE',
'DATABASE_URL',
];
const missingVars = requiredVars.filter((varName) => !config[varName]);
@@ -42,6 +48,7 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
envFilePath: '.env', // Load from .env file
validate: validateEnvironment, // Validate required environment variables
}),
DatabaseModule, // Global database module for Prisma
UsersModule,
AuthModule,
],

View File

@@ -15,9 +15,6 @@ import { UsersModule } from '../users/users.module';
* - Integration with UsersModule for user synchronization
*
* The module requires the following environment variables:
* - KEYCLOAK_AUTH_SERVER_URL: Base URL of Keycloak server
* - KEYCLOAK_REALM: Keycloak realm name
* - KEYCLOAK_CLIENT_ID: Client ID registered in Keycloak
* - JWT_ISSUER: Expected JWT issuer
* - JWT_AUDIENCE: Expected JWT audience
* - JWKS_URI: URI for fetching Keycloak's public keys

View File

@@ -98,7 +98,7 @@ describe('AuthService', () => {
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
lastLoginAt: expect.any(Date),
}),
);

View File

@@ -28,12 +28,12 @@ export class AuthService {
authenticatedUser;
// Try to find existing user by Keycloak subject
let user = this.usersService.findByKeycloakSub(keycloakSub);
let user = await this.usersService.findByKeycloakSub(keycloakSub);
if (user) {
// User exists - update last login and sync profile data
this.logger.debug(`Syncing existing user: ${keycloakSub}`);
user = this.usersService.updateFromToken(keycloakSub, {
user = await this.usersService.updateFromToken(keycloakSub, {
email,
name,
username,
@@ -44,7 +44,7 @@ export class AuthService {
} else {
// New user - create from token data
this.logger.log(`Creating new user from token: ${keycloakSub}`);
user = this.usersService.createFromToken({
user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
name: name || username || 'Unknown User',
@@ -54,7 +54,7 @@ export class AuthService {
});
}
return Promise.resolve(user);
return user;
}
/**

View File

@@ -40,10 +40,10 @@ export interface AuthenticatedUser {
*/
export const CurrentUser = createParamDecorator(
(data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = ctx.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const user = request.user as AuthenticatedUser;
const request = ctx
.switchToHttp()
.getRequest<{ user?: AuthenticatedUser }>();
const user = request.user;
// If a specific property is requested, return only that property
return data ? user?.[data] : user;

View File

@@ -1,6 +1,8 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import type { Request } from 'express';
import { User } from 'src/users/users.entity';
/**
* JWT Authentication Guard
@@ -29,13 +31,18 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Log the authentication attempt
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = context.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers.authorization;
if (!authHeader) {
this.logger.warn('Authentication attempt without Authorization header');
this.logger.warn(
'❌ Authentication attempt without Authorization header',
);
} else {
const tokenPreview = String(authHeader).substring(0, 20);
this.logger.debug(
`🔐 Authentication attempt with token: ${tokenPreview}...`,
);
}
return super.canActivate(context);
@@ -50,24 +57,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(
err: any,
user: any,
user: User,
info: any,
context: ExecutionContext,
status?: any,
): any {
const hasMessage = (value: unknown): value is { message?: unknown } =>
typeof value === 'object' && value !== null && 'message' in value;
if (err || !user) {
const infoMessage =
info && typeof info === 'object' && 'message' in info
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
String(info.message)
: '';
const errMessage =
err && typeof err === 'object' && 'message' in err
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
String(err.message)
: '';
this.logger.warn(
`Authentication failed: ${infoMessage || errMessage || 'Unknown error'}`,
const infoMessage = hasMessage(info) ? String(info.message) : '';
const errMessage = hasMessage(err) ? String(err.message) : '';
this.logger.error(`❌ JWT Authentication failed`);
this.logger.error(` Error: ${errMessage || 'none'}`);
this.logger.error(` Info: ${infoMessage || 'none'}`);
if (info && typeof info === 'object') {
this.logger.error(` Info details: ${JSON.stringify(info)}`);
}
if (err && typeof err === 'object') {
this.logger.error(` Error details: ${JSON.stringify(err)}`);
}
} else {
this.logger.debug(
`✅ JWT Authentication successful for user: ${user.keycloakSub || 'unknown'}`,
);
}

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
/**
@@ -74,7 +75,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
algorithms: ['RS256'],
});
this.logger.log(`JWT Strategy initialized with issuer: ${issuer}`);
this.logger.log(`JWT Strategy initialized`);
this.logger.log(` JWKS URI: ${jwksUri}`);
this.logger.log(` Issuer: ${issuer}`);
this.logger.log(` Audience: ${audience || 'NOT SET'}`);
}
/**
@@ -93,6 +97,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
picture?: string;
roles?: string[];
}> {
this.logger.debug(`Validating JWT token payload`);
this.logger.debug(` Issuer: ${payload.iss}`);
this.logger.debug(
` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`,
);
this.logger.debug(` Subject: ${payload.sub}`);
this.logger.debug(
` Expires: ${new Date(payload.exp * 1000).toISOString()}`,
);
if (!payload.sub) {
this.logger.warn('JWT token missing required "sub" claim');
throw new UnauthorizedException('Invalid token: missing subject');
@@ -120,7 +134,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
roles: roles.length > 0 ? roles : undefined,
};
this.logger.debug(`Validated token for user: ${payload.sub}`);
this.logger.log(
`✅ Successfully validated token for user: ${payload.sub} (${payload.email ?? payload.preferred_username ?? 'no email'})`,
);
return Promise.resolve(user);
}

View File

@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
/**
* Database Module
*
* A global module that provides database access through PrismaService.
* This module is marked as @Global() so PrismaService is available
* throughout the application without needing to import DatabaseModule
* in every feature module.
*/
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,134 @@
import {
Injectable,
OnModuleInit,
OnModuleDestroy,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
/**
* Type definitions for Prisma event payloads
*/
interface QueryEvent {
query: string;
params: string;
duration: number;
}
interface ErrorEvent {
message: string;
}
interface WarnEvent {
message: string;
}
/**
* Prisma Service
*
* Manages the Prisma Client instance and database connection lifecycle.
* Automatically connects on module initialization and disconnects on module destruction.
*
* This service should be used throughout the application to interact with the database.
*/
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
constructor(private configService: ConfigService) {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
// Initialize PostgreSQL connection pool
const pool = new Pool({ connectionString: databaseUrl });
const adapter = new PrismaPg(pool);
// Initialize Prisma Client with the adapter
super({
adapter,
log: [
{ emit: 'event', level: 'query' },
{ emit: 'event', level: 'error' },
{ emit: 'event', level: 'warn' },
],
});
// Log database queries in development mode
if (process.env.NODE_ENV === 'development') {
this.$on('query' as never, (e: QueryEvent) => {
this.logger.debug(`Query: ${e.query}`);
this.logger.debug(`Params: ${e.params}`);
this.logger.debug(`Duration: ${e.duration}ms`);
});
}
// Log errors and warnings
this.$on('error' as never, (e: ErrorEvent) => {
this.logger.error(`Database error: ${e.message}`);
});
this.$on('warn' as never, (e: WarnEvent) => {
this.logger.warn(`Database warning: ${e.message}`);
});
}
/**
* Connect to the database when the module is initialized
*/
async onModuleInit() {
try {
await this.$connect();
this.logger.log('Successfully connected to database');
} catch (error) {
this.logger.error('Failed to connect to database', error);
throw error;
}
}
/**
* Disconnect from the database when the module is destroyed
*/
async onModuleDestroy() {
try {
await this.$disconnect();
this.logger.log('Successfully disconnected from database');
} catch (error) {
this.logger.error('Error disconnecting from database', error);
}
}
/**
* Clean the database (useful for testing)
* WARNING: This will delete all data from all tables
*/
async cleanDatabase() {
if (process.env.NODE_ENV === 'production') {
throw new Error('Cannot clean database in production');
}
const models = Reflect.ownKeys(this).filter(
(key) => key[0] !== '_' && key[0] !== '$',
);
return Promise.all(
models.map((modelKey) => {
const model = this[modelKey as keyof this];
if (model && typeof model === 'object' && 'deleteMany' in model) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
return (model as any).deleteMany();
}
return Promise.resolve();
}),
);
}
}

View File

@@ -42,6 +42,7 @@ describe('UsersController', () => {
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: null,
roles: ['user'],
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
@@ -74,20 +75,20 @@ describe('UsersController', () => {
});
describe('getCurrentUser', () => {
it('should return current user profile and sync from token', async () => {
it('should return the current user and sync from token', async () => {
mockSyncUserFromToken.mockResolvedValue(mockUser);
const result = await controller.getCurrentUser(mockAuthUser);
expect(result).toEqual(mockUser);
expect(result).toBe(mockUser);
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
});
});
describe('updateCurrentUser', () => {
it('should update current user profile', async () => {
it('should update the current user profile', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' };
const updatedUser: User = { ...mockUser, name: 'Updated Name' };
const updatedUser = { ...mockUser, name: 'Updated Name' };
mockSyncUserFromToken.mockResolvedValue(mockUser);
mockUpdate.mockResolvedValue(updatedUser);
@@ -97,7 +98,7 @@ describe('UsersController', () => {
updateDto,
);
expect(result).toEqual(updatedUser);
expect(result).toBe(updatedUser);
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
expect(mockUpdate).toHaveBeenCalledWith(
mockUser.id,
@@ -139,35 +140,33 @@ describe('UsersController', () => {
});
describe('update', () => {
it('should update a user by id', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' };
const updatedUser: User = { ...mockUser, name: 'Updated Name' };
it('should update a user profile', async () => {
const updateDto: UpdateUserDto = { name: 'New Name' };
const updatedUser = { ...mockUser, name: 'New Name' };
mockUpdate.mockReturnValue(updatedUser);
mockUpdate.mockResolvedValue(updatedUser);
const result = await controller.update(
'uuid-123',
mockUser.id,
updateDto,
mockAuthUser,
);
expect(result).toEqual(updatedUser);
expect(result).toBe(updatedUser);
expect(mockUpdate).toHaveBeenCalledWith(
'uuid-123',
mockUser.id,
updateDto,
mockAuthUser.keycloakSub,
);
});
it('should throw ForbiddenException when trying to update another user', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' };
mockUpdate.mockImplementation(() => {
throw new ForbiddenException('You can only update your own profile');
});
it('should throw ForbiddenException if updating another user', async () => {
mockUpdate.mockRejectedValue(
new ForbiddenException('You can only update your own profile'),
);
await expect(
controller.update('different-uuid', updateDto, mockAuthUser),
controller.update(mockUser.id, { name: 'Hacker' }, mockAuthUser),
).rejects.toThrow(ForbiddenException);
});
@@ -185,9 +184,9 @@ describe('UsersController', () => {
});
describe('deleteCurrentUser', () => {
it('should delete current user account', async () => {
it('should delete the current user account', async () => {
mockSyncUserFromToken.mockResolvedValue(mockUser);
mockDelete.mockReturnValue(undefined);
mockDelete.mockResolvedValue(undefined);
await controller.deleteCurrentUser(mockAuthUser);
@@ -200,35 +199,33 @@ describe('UsersController', () => {
});
describe('delete', () => {
it('should delete a user by id', () => {
mockDelete.mockReturnValue(undefined);
it('should delete a user by ID', async () => {
mockDelete.mockResolvedValue(undefined);
controller.delete('uuid-123', mockAuthUser);
await controller.delete(mockUser.id, mockAuthUser);
expect(mockDelete).toHaveBeenCalledWith(
'uuid-123',
mockUser.id,
mockAuthUser.keycloakSub,
);
});
it('should throw ForbiddenException when trying to delete another user', () => {
mockDelete.mockImplementation(() => {
throw new ForbiddenException('You can only delete your own account');
});
expect(() => controller.delete('different-uuid', mockAuthUser)).toThrow(
ForbiddenException,
it('should throw ForbiddenException if deleting another user', async () => {
mockDelete.mockRejectedValue(
new ForbiddenException('You can only delete your own account'),
);
await expect(
controller.delete(mockUser.id, mockAuthUser),
).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException if user not found', () => {
mockDelete.mockImplementation(() => {
throw new NotFoundException('User with ID non-existent not found');
});
it('should throw NotFoundException if user not found', async () => {
mockDelete.mockRejectedValue(new NotFoundException('User not found'));
expect(() => controller.delete('non-existent', mockAuthUser)).toThrow(
NotFoundException,
);
await expect(
controller.delete('non-existent-id', mockAuthUser),
).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -19,7 +19,7 @@ import {
ApiForbiddenResponse,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { User } from './users.entity';
import { User, UserResponseDto } from './users.entity';
import { UpdateUserDto } from './dto/update-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
@@ -62,7 +62,7 @@ export class UsersController {
@ApiResponse({
status: 200,
description: 'Current user profile',
type: User,
type: UserResponseDto,
})
@ApiUnauthorizedResponse({
description: 'Invalid or missing JWT token',
@@ -90,7 +90,7 @@ export class UsersController {
@ApiResponse({
status: 200,
description: 'User profile updated successfully',
type: User,
type: UserResponseDto,
})
@ApiResponse({
status: 400,
@@ -109,8 +109,10 @@ export class UsersController {
const user = await this.authService.syncUserFromToken(authUser);
// Update the user's profile
return Promise.resolve(
this.usersService.update(user.id, updateUserDto, authUser.keycloakSub),
return this.usersService.update(
user.id,
updateUserDto,
authUser.keycloakSub,
);
}
@@ -132,7 +134,7 @@ export class UsersController {
@ApiResponse({
status: 200,
description: 'User found',
type: User,
type: UserResponseDto,
})
@ApiResponse({
status: 404,
@@ -148,7 +150,7 @@ export class UsersController {
this.logger.debug(
`Get user by ID: ${id} (requested by ${authUser.keycloakSub})`,
);
return Promise.resolve(this.usersService.findOne(id));
return this.usersService.findOne(id);
}
/**
@@ -169,7 +171,7 @@ export class UsersController {
@ApiResponse({
status: 200,
description: 'User updated successfully',
type: User,
type: UserResponseDto,
})
@ApiResponse({
status: 400,
@@ -191,9 +193,7 @@ export class UsersController {
@CurrentUser() authUser: AuthenticatedUser,
): Promise<User> {
this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`);
return Promise.resolve(
this.usersService.update(id, updateUserDto, authUser.keycloakSub),
);
return this.usersService.update(id, updateUserDto, authUser.keycloakSub);
}
/**
@@ -224,7 +224,7 @@ export class UsersController {
const user = await this.authService.syncUserFromToken(authUser);
// Delete the user's account
this.usersService.delete(user.id, authUser.keycloakSub);
await this.usersService.delete(user.id, authUser.keycloakSub);
}
/**
@@ -257,11 +257,11 @@ export class UsersController {
description: 'Invalid or missing JWT token',
})
@HttpCode(204)
delete(
async delete(
@Param('id') id: string,
@CurrentUser() authUser: AuthenticatedUser,
): void {
): Promise<void> {
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`);
this.usersService.delete(id, authUser.keycloakSub);
await this.usersService.delete(id, authUser.keycloakSub);
}
}

View File

@@ -1,102 +1,85 @@
import { ApiProperty } from '@nestjs/swagger';
import { User as PrismaUser } from '@prisma/client';
/**
* User entity representing a user in the system.
* Users are synced from Keycloak via OIDC authentication.
*
* This is a re-export of the Prisma User type for consistency.
* Swagger decorators are applied at the controller level.
*/
export class User {
export type User = PrismaUser;
/**
* Internal unique identifier (UUID)
* User response DTO for Swagger documentation
* This class is only used for API documentation purposes
*/
export class UserResponseDto implements PrismaUser {
@ApiProperty({
description: 'Internal unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000',
})
id: string;
/**
* Keycloak subject identifier (unique per user in Keycloak)
*/
@ApiProperty({
description: 'Keycloak subject identifier from the JWT token',
example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe',
})
keycloakSub: string;
/**
* User's display name
*/
@ApiProperty({
description: "User's display name",
example: 'John Doe',
})
name: string;
/**
* User's email address
*/
@ApiProperty({
description: "User's email address",
example: 'john.doe@example.com',
})
email: string;
/**
* User's preferred username from Keycloak
*/
@ApiProperty({
description: "User's preferred username from Keycloak",
example: 'johndoe',
required: false,
nullable: true,
})
username?: string;
username: string | null;
/**
* URL to user's profile picture
*/
@ApiProperty({
description: "URL to user's profile picture",
example: 'https://example.com/avatars/johndoe.jpg',
required: false,
nullable: true,
})
picture?: string;
picture: string | null;
/**
* User's roles from Keycloak
*/
@ApiProperty({
description: "User's roles from Keycloak",
example: ['user', 'premium'],
type: [String],
required: false,
isArray: true,
})
roles?: string[];
roles: string[];
/**
* Timestamp when the user was first created in the system
*/
@ApiProperty({
description: 'Timestamp when the user was first created',
example: '2024-01-15T10:30:00.000Z',
})
createdAt: Date;
/**
* Timestamp when the user profile was last updated
*/
@ApiProperty({
description: 'Timestamp when the user was last updated',
example: '2024-01-20T14:45:00.000Z',
})
updatedAt: Date;
/**
* Timestamp of last login
*/
@ApiProperty({
description: 'Timestamp of last login',
example: '2024-01-20T14:45:00.000Z',
required: false,
nullable: true,
})
lastLoginAt?: Date;
lastLoginAt: Date | null;
}

View File

@@ -1,17 +1,52 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../database/prisma.service';
import type { UpdateUserDto } from './dto/update-user.dto';
import type { User } from '@prisma/client';
describe('UsersService', () => {
let service: UsersService;
let prismaService: PrismaService;
const mockUser: User = {
id: '550e8400-e29b-41d4-a716-446655440000',
keycloakSub: 'f:realm:user123',
email: 'john@example.com',
name: 'John Doe',
username: 'johndoe',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
createdAt: new Date('2024-01-15T10:30:00.000Z'),
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
lastLoginAt: new Date('2024-01-15T10:30:00.000Z'),
};
const mockPrismaService = {
user: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
providers: [
UsersService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<UsersService>(UsersService);
prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks before each test
jest.clearAllMocks();
});
it('should be defined', () => {
@@ -19,7 +54,7 @@ describe('UsersService', () => {
});
describe('createFromToken', () => {
it('should create a new user from Keycloak token data', () => {
it('should create a new user from Keycloak token data', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'john@example.com',
@@ -29,259 +64,258 @@ describe('UsersService', () => {
roles: ['user', 'premium'],
};
const user = service.createFromToken(tokenData);
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.user.create.mockResolvedValue(mockUser);
const user = await service.createFromToken(tokenData);
expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(typeof user.id).toBe('string');
expect(user.keycloakSub).toBe('f:realm:user123');
expect(user.email).toBe('john@example.com');
expect(user.name).toBe('John Doe');
expect(user.username).toBe('johndoe');
expect(user.picture).toBe('https://example.com/avatar.jpg');
expect(user.roles).toEqual(['user', 'premium']);
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.updatedAt).toBeInstanceOf(Date);
expect(user.lastLoginAt).toBeInstanceOf(Date);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
});
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
keycloakSub: tokenData.keycloakSub,
email: tokenData.email,
name: tokenData.name,
username: tokenData.username,
picture: tokenData.picture,
roles: tokenData.roles,
}),
});
});
it('should return existing user if keycloakSub already exists', () => {
it('should return existing user if keycloakSub already exists', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'john@example.com',
name: 'John Doe',
};
const user1 = service.createFromToken(tokenData);
const user2 = service.createFromToken(tokenData);
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
expect(user1.id).toBe(user2.id);
expect(user1.keycloakSub).toBe(user2.keycloakSub);
const user = await service.createFromToken(tokenData);
expect(user).toEqual(mockUser);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
});
expect(mockPrismaService.user.create).not.toHaveBeenCalled();
});
it('should handle optional fields', async () => {
const tokenData = {
keycloakSub: 'f:realm:user456',
email: 'jane@example.com',
name: 'Jane Doe',
};
const newUser = { ...mockUser, username: null, picture: null, roles: [] };
mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.user.create.mockResolvedValue(newUser);
const user = await service.createFromToken(tokenData);
expect(user).toBeDefined();
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
keycloakSub: tokenData.keycloakSub,
email: tokenData.email,
name: tokenData.name,
username: undefined,
picture: undefined,
roles: [],
}),
});
});
});
describe('findByKeycloakSub', () => {
it('should return the user if found by keycloakSub', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
it('should return a user by keycloakSub', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
const user = await service.findByKeycloakSub('f:realm:user123');
expect(user).toEqual(mockUser);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
});
});
const user = service.findByKeycloakSub('f:realm:user123');
it('should return null if user not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
expect(user).toBeDefined();
expect(user?.id).toBe(createdUser.id);
expect(user?.keycloakSub).toBe('f:realm:user123');
});
const user = await service.findByKeycloakSub('nonexistent');
it('should return null if user not found by keycloakSub', () => {
const user = service.findByKeycloakSub('non-existent-sub');
expect(user).toBeNull();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: 'nonexistent' },
});
});
});
describe('findOne', () => {
it('should return the user if found by ID', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
it('should return a user by ID', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
const user = await service.findOne(mockUser.id);
expect(user).toEqual(mockUser);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { id: mockUser.id },
});
});
const user = service.findOne(createdUser.id);
it('should throw NotFoundException if user not found', async () => {
const nonexistentId = 'nonexistent-id';
mockPrismaService.user.findUnique.mockResolvedValue(null);
expect(user).toEqual(createdUser);
});
it('should throw NotFoundException if user not found by ID', () => {
expect(() => service.findOne('non-existent-id')).toThrow(
await expect(service.findOne(nonexistentId)).rejects.toThrow(
NotFoundException,
);
expect(() => service.findOne('non-existent-id')).toThrow(
'User with ID non-existent-id not found',
await expect(service.findOne(nonexistentId)).rejects.toThrow(
`User with ID ${nonexistentId} not found`,
);
});
});
describe('updateFromToken', () => {
it('should update user data from token and set lastLoginAt', async () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'old@example.com',
name: 'Old Name',
});
const originalUpdatedAt = createdUser.updatedAt;
// Wait a bit to ensure timestamp difference
await new Promise((resolve) => setTimeout(resolve, 10));
const updatedUser = service.updateFromToken('f:realm:user123', {
email: 'new@example.com',
name: 'New Name',
username: 'newusername',
roles: ['admin'],
lastLoginAt: new Date(),
});
expect(updatedUser.id).toBe(createdUser.id);
expect(updatedUser.email).toBe('new@example.com');
expect(updatedUser.name).toBe('New Name');
expect(updatedUser.username).toBe('newusername');
expect(updatedUser.roles).toEqual(['admin']);
expect(updatedUser.lastLoginAt).toBeDefined();
expect(updatedUser.updatedAt.getTime()).toBeGreaterThan(
originalUpdatedAt.getTime(),
);
});
it('should throw NotFoundException if user not found', () => {
expect(() =>
service.updateFromToken('non-existent-sub', {
email: 'test@example.com',
}),
).toThrow(NotFoundException);
});
it('should only update provided fields', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'original@example.com',
name: 'Original Name',
username: 'original',
});
const updatedUser = service.updateFromToken('f:realm:user123', {
it('should update user from token data', async () => {
const updateData = {
email: 'updated@example.com',
// name and username not provided
name: 'Updated Name',
lastLoginAt: new Date('2024-01-20T14:45:00.000Z'),
};
const updatedUser = { ...mockUser, ...updateData };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.updateFromToken('f:realm:user123', updateData);
expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: expect.objectContaining({
email: updateData.email,
name: updateData.name,
lastLoginAt: updateData.lastLoginAt,
}),
});
});
expect(updatedUser.email).toBe('updated@example.com');
expect(updatedUser.name).toBe('Original Name'); // unchanged
expect(updatedUser.username).toBe('original'); // unchanged
it('should throw NotFoundException if user not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(
service.updateFromToken('nonexistent', { name: 'Test' }),
).rejects.toThrow(NotFoundException);
});
it('should only update provided fields', async () => {
const updateData = {
name: 'New Name',
};
const updatedUser = { ...mockUser, ...updateData };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.updateFromToken('f:realm:user123', updateData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: { name: 'New Name' },
});
});
});
describe('update', () => {
it('should update user profile when user updates their own profile', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Old Name',
});
const updateUserDto: UpdateUserDto = {
name: 'New Name',
it('should update user profile', async () => {
const updateDto: UpdateUserDto = {
name: 'Updated Name',
};
const updatedUser = service.update(
createdUser.id,
updateUserDto,
'f:realm:user123',
const updatedUser = { ...mockUser, name: updateDto.name };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.update(
mockUser.id,
updateDto,
mockUser.keycloakSub,
);
expect(updatedUser.id).toBe(createdUser.id);
expect(updatedUser.name).toBe('New Name');
expect(user.name).toBe(updateDto.name);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { id: mockUser.id },
data: { name: updateDto.name },
});
});
it('should throw ForbiddenException when user tries to update another user', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
it('should throw ForbiddenException if user tries to update someone else', async () => {
const updateDto: UpdateUserDto = {
name: 'Hacker Name',
};
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect(
service.update(mockUser.id, updateDto, 'different-keycloak-sub'),
).rejects.toThrow(ForbiddenException);
expect(mockPrismaService.user.update).not.toHaveBeenCalled();
});
const updateUserDto: UpdateUserDto = { name: 'New Name' };
it('should throw NotFoundException if user not found', async () => {
const updateDto: UpdateUserDto = {
name: 'Test',
};
expect(() =>
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
).toThrow(ForbiddenException);
mockPrismaService.user.findUnique.mockResolvedValue(null);
expect(() =>
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
).toThrow('You can only update your own profile');
});
it('should throw NotFoundException if user not found', () => {
const updateUserDto: UpdateUserDto = { name: 'New Name' };
expect(() =>
service.update('non-existent-id', updateUserDto, 'f:realm:user123'),
).toThrow(NotFoundException);
});
it('should update user profile with empty name', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Old Name',
});
const updateUserDto: UpdateUserDto = { name: '' };
const updatedUser = service.update(
createdUser.id,
updateUserDto,
'f:realm:user123',
);
expect(updatedUser.name).toBe('');
});
it('should update user profile with very long name', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Old Name',
});
const longName = 'A'.repeat(200); // Exceeds typical limits
const updateUserDto: UpdateUserDto = { name: longName };
const updatedUser = service.update(
createdUser.id,
updateUserDto,
'f:realm:user123',
);
expect(updatedUser.name).toBe(longName);
await expect(
service.update('nonexistent', updateDto, 'any-keycloak-sub'),
).rejects.toThrow(NotFoundException);
});
});
describe('delete', () => {
it('should delete user when user deletes their own account', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'delete@example.com',
name: 'To Delete',
it('should delete user account', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.delete.mockResolvedValue(mockUser);
await service.delete(mockUser.id, mockUser.keycloakSub);
expect(mockPrismaService.user.delete).toHaveBeenCalledWith({
where: { id: mockUser.id },
});
});
service.delete(createdUser.id, 'f:realm:user123');
it('should throw ForbiddenException if user tries to delete someone else', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
expect(() => service.findOne(createdUser.id)).toThrow(NotFoundException);
await expect(
service.delete(mockUser.id, 'different-keycloak-sub'),
).rejects.toThrow(ForbiddenException);
expect(mockPrismaService.user.delete).not.toHaveBeenCalled();
});
it('should throw ForbiddenException when user tries to delete another user', () => {
const createdUser = service.createFromToken({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
});
it('should throw NotFoundException if user not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
expect(() =>
service.delete(createdUser.id, 'f:realm:differentuser'),
).toThrow(ForbiddenException);
expect(() =>
service.delete(createdUser.id, 'f:realm:differentuser'),
).toThrow('You can only delete your own account');
});
it('should throw NotFoundException if user not found', () => {
expect(() =>
service.delete('non-existent-id', 'f:realm:user123'),
).toThrow(NotFoundException);
await expect(
service.delete('nonexistent', 'any-keycloak-sub'),
).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -4,8 +4,8 @@ import {
ForbiddenException,
Logger,
} from '@nestjs/common';
import { randomUUID } from 'crypto';
import { User } from './users.entity';
import { PrismaService } from '../database/prisma.service';
import { User } from '@prisma/client';
import type { UpdateUserDto } from './dto/update-user.dto';
/**
@@ -35,14 +35,15 @@ export interface UpdateUserFromTokenDto {
/**
* Users Service
*
* Manages user data synchronized from Keycloak OIDC.
* Manages user data synchronized from Keycloak OIDC using Prisma ORM.
* Users are created automatically when they first authenticate via Keycloak.
* Direct user creation is not allowed - users must authenticate via Keycloak first.
*/
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
private users: User[] = [];
constructor(private readonly prisma: PrismaService) {}
/**
* Creates a new user from Keycloak token data.
@@ -51,10 +52,9 @@ export class UsersService {
* @param createDto - User data extracted from Keycloak JWT token
* @returns The newly created user
*/
createFromToken(createDto: CreateUserFromTokenDto): User {
const existingUser = this.users.find(
(u) => u.keycloakSub === createDto.keycloakSub,
);
async createFromToken(createDto: CreateUserFromTokenDto): Promise<User> {
// Check if user already exists
const existingUser = await this.findByKeycloakSub(createDto.keycloakSub);
if (existingUser) {
this.logger.warn(
@@ -63,19 +63,17 @@ export class UsersService {
return existingUser;
}
const newUser = new User();
newUser.id = randomUUID();
newUser.keycloakSub = createDto.keycloakSub;
newUser.email = createDto.email;
newUser.name = createDto.name;
newUser.username = createDto.username;
newUser.picture = createDto.picture;
newUser.roles = createDto.roles;
newUser.createdAt = new Date();
newUser.updatedAt = new Date();
newUser.lastLoginAt = new Date();
this.users.push(newUser);
const newUser = await this.prisma.user.create({
data: {
keycloakSub: createDto.keycloakSub,
email: createDto.email,
name: createDto.name,
username: createDto.username,
picture: createDto.picture,
roles: createDto.roles || [],
lastLoginAt: new Date(),
},
});
this.logger.log(`Created new user: ${newUser.id} (${newUser.keycloakSub})`);
@@ -88,9 +86,12 @@ export class UsersService {
* @param keycloakSub - The Keycloak subject (sub claim from JWT)
* @returns The user if found, null otherwise
*/
findByKeycloakSub(keycloakSub: string): User | null {
const user = this.users.find((u) => u.keycloakSub === keycloakSub);
return user || null;
async findByKeycloakSub(keycloakSub: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({
where: { keycloakSub },
});
return user;
}
/**
@@ -100,11 +101,15 @@ export class UsersService {
* @returns The user entity
* @throws NotFoundException if the user is not found
*/
findOne(id: string): User {
const user = this.users.find((u) => u.id === id);
async findOne(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
@@ -117,31 +122,47 @@ export class UsersService {
* @returns The updated user
* @throws NotFoundException if the user is not found
*/
updateFromToken(
async updateFromToken(
keycloakSub: string,
updateDto: UpdateUserFromTokenDto,
): User {
const user = this.findByKeycloakSub(keycloakSub);
): Promise<User> {
const user = await this.findByKeycloakSub(keycloakSub);
if (!user) {
throw new NotFoundException(
`User with keycloakSub ${keycloakSub} not found`,
);
}
// Update user properties from token
if (updateDto.email !== undefined) user.email = updateDto.email;
if (updateDto.name !== undefined) user.name = updateDto.name;
if (updateDto.username !== undefined) user.username = updateDto.username;
if (updateDto.picture !== undefined) user.picture = updateDto.picture;
if (updateDto.roles !== undefined) user.roles = updateDto.roles;
// Prepare update data - only include defined fields
const updateData: {
email?: string;
name?: string;
username?: string;
picture?: string;
roles?: string[];
lastLoginAt?: Date;
} = {};
if (updateDto.email !== undefined) updateData.email = updateDto.email;
if (updateDto.name !== undefined) updateData.name = updateDto.name;
if (updateDto.username !== undefined)
updateData.username = updateDto.username;
if (updateDto.picture !== undefined) updateData.picture = updateDto.picture;
if (updateDto.roles !== undefined) updateData.roles = updateDto.roles;
if (updateDto.lastLoginAt !== undefined)
user.lastLoginAt = updateDto.lastLoginAt;
updateData.lastLoginAt = updateDto.lastLoginAt;
user.updatedAt = new Date();
const updatedUser = await this.prisma.user.update({
where: { keycloakSub },
data: updateData,
});
this.logger.debug(`Synced user from token: ${user.id} (${keycloakSub})`);
this.logger.debug(
`Synced user from token: ${updatedUser.id} (${keycloakSub})`,
);
return user;
return updatedUser;
}
/**
@@ -155,12 +176,12 @@ export class UsersService {
* @throws NotFoundException if the user is not found
* @throws ForbiddenException if the user tries to update someone else's profile
*/
update(
async update(
id: string,
updateUserDto: UpdateUserDto,
requestingUserKeycloakSub: string,
): User {
const user = this.findOne(id);
): Promise<User> {
const user = await this.findOne(id);
// Verify the user is updating their own profile
if (user.keycloakSub !== requestingUserKeycloakSub) {
@@ -172,15 +193,22 @@ export class UsersService {
// Only allow updating specific fields via the public API
// Security-sensitive fields (keycloakSub, roles, etc.) cannot be updated
const updateData: {
name?: string;
} = {};
if (updateUserDto.name !== undefined) {
user.name = updateUserDto.name;
updateData.name = updateUserDto.name;
}
user.updatedAt = new Date();
const updatedUser = await this.prisma.user.update({
where: { id },
data: updateData,
});
this.logger.log(`User ${id} updated their profile`);
return user;
return updatedUser;
}
/**
@@ -193,8 +221,8 @@ export class UsersService {
* @throws NotFoundException if the user is not found
* @throws ForbiddenException if the user tries to delete someone else's account
*/
delete(id: string, requestingUserKeycloakSub: string): void {
const user = this.findOne(id);
async delete(id: string, requestingUserKeycloakSub: string): Promise<void> {
const user = await this.findOne(id);
// Verify the user is deleting their own account
if (user.keycloakSub !== requestingUserKeycloakSub) {
@@ -204,12 +232,9 @@ export class UsersService {
throw new ForbiddenException('You can only delete your own account');
}
const index = this.users.findIndex((u) => u.id === id);
if (index === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}
this.users.splice(index, 1);
await this.prisma.user.delete({
where: { id },
});
this.logger.log(
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,