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 PORT=3000
NODE_ENV=development NODE_ENV=development
# Keycloak OpenID Connect Configuration # Database connection string
# The base URL of your Keycloak server (e.g., https://keycloak.example.com) DATABASE_URL="postgresql://postgres:postgres@localhost:5432/friendolls_dev?schema=public"
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=
# JWT Configuration # JWT Configuration
# The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}) # 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) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 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-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn',
'@typescript-eslint/no-unsafe-return': 'error',
'prettier/prettier': ['error', { endOfLine: 'auto' }], 'prettier/prettier': ['error', { endOfLine: 'auto' }],
}, },
}, },
@@ -38,6 +39,7 @@ export default tseslint.config(
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
}, },
}, },
); );

View File

@@ -17,7 +17,14 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@@ -26,12 +33,15 @@
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
"@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"jwks-rsa": "^3.2.0", "jwks-rsa": "^3.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@@ -44,6 +54,8 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
@@ -51,6 +63,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^7.0.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "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: onlyBuiltDependencies:
- '@nestjs/core' - '@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 { AppService } from './app.service';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DatabaseModule } from './database/database.module';
/** /**
* Validates required environment variables. * Validates required environment variables.
@@ -11,7 +12,12 @@ import { AuthModule } from './auth/auth.module';
* Returns the validated config. * Returns the validated config.
*/ */
function validateEnvironment(config: Record<string, any>): Record<string, any> { 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]); 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 envFilePath: '.env', // Load from .env file
validate: validateEnvironment, // Validate required environment variables validate: validateEnvironment, // Validate required environment variables
}), }),
DatabaseModule, // Global database module for Prisma
UsersModule, UsersModule,
AuthModule, AuthModule,
], ],

View File

@@ -15,9 +15,6 @@ import { UsersModule } from '../users/users.module';
* - Integration with UsersModule for user synchronization * - Integration with UsersModule for user synchronization
* *
* The module requires the following environment variables: * 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_ISSUER: Expected JWT issuer
* - JWT_AUDIENCE: Expected JWT audience * - JWT_AUDIENCE: Expected JWT audience
* - JWKS_URI: URI for fetching Keycloak's public keys * - JWKS_URI: URI for fetching Keycloak's public keys

View File

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

View File

@@ -28,12 +28,12 @@ export class AuthService {
authenticatedUser; authenticatedUser;
// Try to find existing user by Keycloak subject // Try to find existing user by Keycloak subject
let user = this.usersService.findByKeycloakSub(keycloakSub); let user = await this.usersService.findByKeycloakSub(keycloakSub);
if (user) { if (user) {
// User exists - update last login and sync profile data // User exists - update last login and sync profile data
this.logger.debug(`Syncing existing user: ${keycloakSub}`); this.logger.debug(`Syncing existing user: ${keycloakSub}`);
user = this.usersService.updateFromToken(keycloakSub, { user = await this.usersService.updateFromToken(keycloakSub, {
email, email,
name, name,
username, username,
@@ -44,7 +44,7 @@ export class AuthService {
} else { } else {
// New user - create from token data // New user - create from token data
this.logger.log(`Creating new user from token: ${keycloakSub}`); this.logger.log(`Creating new user from token: ${keycloakSub}`);
user = this.usersService.createFromToken({ user = await this.usersService.createFromToken({
keycloakSub, keycloakSub,
email: email || '', email: email || '',
name: name || username || 'Unknown User', 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( export const CurrentUser = createParamDecorator(
(data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => { (data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const request = ctx
const request = ctx.switchToHttp().getRequest(); .switchToHttp()
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access .getRequest<{ user?: AuthenticatedUser }>();
const user = request.user as AuthenticatedUser; const user = request.user;
// If a specific property is requested, return only that property // If a specific property is requested, return only that property
return data ? user?.[data] : user; return data ? user?.[data] : user;

View File

@@ -1,6 +1,8 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import type { Request } from 'express';
import { User } from 'src/users/users.entity';
/** /**
* JWT Authentication Guard * JWT Authentication Guard
@@ -29,13 +31,18 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
context: ExecutionContext, context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> { ): boolean | Promise<boolean> | Observable<boolean> {
// Log the authentication attempt // Log the authentication attempt
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const request = context.switchToHttp().getRequest<Request>();
const request = context.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
if (!authHeader) { 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); return super.canActivate(context);
@@ -50,24 +57,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest( handleRequest(
err: any, err: any,
user: any, user: User,
info: any, info: any,
context: ExecutionContext, context: ExecutionContext,
status?: any, status?: any,
): any { ): any {
const hasMessage = (value: unknown): value is { message?: unknown } =>
typeof value === 'object' && value !== null && 'message' in value;
if (err || !user) { if (err || !user) {
const infoMessage = const infoMessage = hasMessage(info) ? String(info.message) : '';
info && typeof info === 'object' && 'message' in info const errMessage = hasMessage(err) ? String(err.message) : '';
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
String(info.message) this.logger.error(`❌ JWT Authentication failed`);
: ''; this.logger.error(` Error: ${errMessage || 'none'}`);
const errMessage = this.logger.error(` Info: ${infoMessage || 'none'}`);
err && typeof err === 'object' && 'message' in err
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (info && typeof info === 'object') {
String(err.message) this.logger.error(` Info details: ${JSON.stringify(info)}`);
: ''; }
this.logger.warn(
`Authentication failed: ${infoMessage || errMessage || 'Unknown error'}`, 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 { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt'; import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa'; import { passportJwtSecret } from 'jwks-rsa';
/** /**
@@ -74,7 +75,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
algorithms: ['RS256'], 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; picture?: string;
roles?: 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) { if (!payload.sub) {
this.logger.warn('JWT token missing required "sub" claim'); this.logger.warn('JWT token missing required "sub" claim');
throw new UnauthorizedException('Invalid token: missing subject'); throw new UnauthorizedException('Invalid token: missing subject');
@@ -120,7 +134,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
roles: roles.length > 0 ? roles : undefined, 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); 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', email: 'test@example.com',
name: 'Test User', name: 'Test User',
username: 'testuser', username: 'testuser',
picture: null,
roles: ['user'], roles: ['user'],
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'),
@@ -74,20 +75,20 @@ describe('UsersController', () => {
}); });
describe('getCurrentUser', () => { 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); mockSyncUserFromToken.mockResolvedValue(mockUser);
const result = await controller.getCurrentUser(mockAuthUser); const result = await controller.getCurrentUser(mockAuthUser);
expect(result).toEqual(mockUser); expect(result).toBe(mockUser);
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
}); });
}); });
describe('updateCurrentUser', () => { describe('updateCurrentUser', () => {
it('should update current user profile', async () => { it('should update the current user profile', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' }; const updateDto: UpdateUserDto = { name: 'Updated Name' };
const updatedUser: User = { ...mockUser, name: 'Updated Name' }; const updatedUser = { ...mockUser, name: 'Updated Name' };
mockSyncUserFromToken.mockResolvedValue(mockUser); mockSyncUserFromToken.mockResolvedValue(mockUser);
mockUpdate.mockResolvedValue(updatedUser); mockUpdate.mockResolvedValue(updatedUser);
@@ -97,7 +98,7 @@ describe('UsersController', () => {
updateDto, updateDto,
); );
expect(result).toEqual(updatedUser); expect(result).toBe(updatedUser);
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
expect(mockUpdate).toHaveBeenCalledWith( expect(mockUpdate).toHaveBeenCalledWith(
mockUser.id, mockUser.id,
@@ -139,35 +140,33 @@ describe('UsersController', () => {
}); });
describe('update', () => { describe('update', () => {
it('should update a user by id', async () => { it('should update a user profile', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' }; const updateDto: UpdateUserDto = { name: 'New Name' };
const updatedUser: User = { ...mockUser, name: 'Updated Name' }; const updatedUser = { ...mockUser, name: 'New Name' };
mockUpdate.mockReturnValue(updatedUser); mockUpdate.mockResolvedValue(updatedUser);
const result = await controller.update( const result = await controller.update(
'uuid-123', mockUser.id,
updateDto, updateDto,
mockAuthUser, mockAuthUser,
); );
expect(result).toEqual(updatedUser); expect(result).toBe(updatedUser);
expect(mockUpdate).toHaveBeenCalledWith( expect(mockUpdate).toHaveBeenCalledWith(
'uuid-123', mockUser.id,
updateDto, updateDto,
mockAuthUser.keycloakSub, mockAuthUser.keycloakSub,
); );
}); });
it('should throw ForbiddenException when trying to update another user', async () => { it('should throw ForbiddenException if updating another user', async () => {
const updateDto: UpdateUserDto = { name: 'Updated Name' }; mockUpdate.mockRejectedValue(
new ForbiddenException('You can only update your own profile'),
mockUpdate.mockImplementation(() => { );
throw new ForbiddenException('You can only update your own profile');
});
await expect( await expect(
controller.update('different-uuid', updateDto, mockAuthUser), controller.update(mockUser.id, { name: 'Hacker' }, mockAuthUser),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
@@ -185,9 +184,9 @@ describe('UsersController', () => {
}); });
describe('deleteCurrentUser', () => { describe('deleteCurrentUser', () => {
it('should delete current user account', async () => { it('should delete the current user account', async () => {
mockSyncUserFromToken.mockResolvedValue(mockUser); mockSyncUserFromToken.mockResolvedValue(mockUser);
mockDelete.mockReturnValue(undefined); mockDelete.mockResolvedValue(undefined);
await controller.deleteCurrentUser(mockAuthUser); await controller.deleteCurrentUser(mockAuthUser);
@@ -200,35 +199,33 @@ describe('UsersController', () => {
}); });
describe('delete', () => { describe('delete', () => {
it('should delete a user by id', () => { it('should delete a user by ID', async () => {
mockDelete.mockReturnValue(undefined); mockDelete.mockResolvedValue(undefined);
controller.delete('uuid-123', mockAuthUser); await controller.delete(mockUser.id, mockAuthUser);
expect(mockDelete).toHaveBeenCalledWith( expect(mockDelete).toHaveBeenCalledWith(
'uuid-123', mockUser.id,
mockAuthUser.keycloakSub, mockAuthUser.keycloakSub,
); );
}); });
it('should throw ForbiddenException when trying to delete another user', () => { it('should throw ForbiddenException if deleting another user', async () => {
mockDelete.mockImplementation(() => { mockDelete.mockRejectedValue(
throw new ForbiddenException('You can only delete your own account'); new ForbiddenException('You can only delete your own account'),
});
expect(() => controller.delete('different-uuid', mockAuthUser)).toThrow(
ForbiddenException,
); );
await expect(
controller.delete(mockUser.id, mockAuthUser),
).rejects.toThrow(ForbiddenException);
}); });
it('should throw NotFoundException if user not found', () => { it('should throw NotFoundException if user not found', async () => {
mockDelete.mockImplementation(() => { mockDelete.mockRejectedValue(new NotFoundException('User not found'));
throw new NotFoundException('User with ID non-existent not found');
});
expect(() => controller.delete('non-existent', mockAuthUser)).toThrow( await expect(
NotFoundException, controller.delete('non-existent-id', mockAuthUser),
); ).rejects.toThrow(NotFoundException);
}); });
}); });
}); });

View File

@@ -19,7 +19,7 @@ import {
ApiForbiddenResponse, ApiForbiddenResponse,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { User } from './users.entity'; import { User, UserResponseDto } from './users.entity';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { import {
@@ -62,7 +62,7 @@ export class UsersController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Current user profile', description: 'Current user profile',
type: User, type: UserResponseDto,
}) })
@ApiUnauthorizedResponse({ @ApiUnauthorizedResponse({
description: 'Invalid or missing JWT token', description: 'Invalid or missing JWT token',
@@ -90,7 +90,7 @@ export class UsersController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'User profile updated successfully', description: 'User profile updated successfully',
type: User, type: UserResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: 400, status: 400,
@@ -109,8 +109,10 @@ export class UsersController {
const user = await this.authService.syncUserFromToken(authUser); const user = await this.authService.syncUserFromToken(authUser);
// Update the user's profile // Update the user's profile
return Promise.resolve( return this.usersService.update(
this.usersService.update(user.id, updateUserDto, authUser.keycloakSub), user.id,
updateUserDto,
authUser.keycloakSub,
); );
} }
@@ -132,7 +134,7 @@ export class UsersController {
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'User found', description: 'User found',
type: User, type: UserResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: 404, status: 404,
@@ -148,7 +150,7 @@ export class UsersController {
this.logger.debug( this.logger.debug(
`Get user by ID: ${id} (requested by ${authUser.keycloakSub})`, `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({ @ApiResponse({
status: 200, status: 200,
description: 'User updated successfully', description: 'User updated successfully',
type: User, type: UserResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: 400, status: 400,
@@ -191,9 +193,7 @@ export class UsersController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): Promise<User> {
this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`); this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`);
return Promise.resolve( return this.usersService.update(id, updateUserDto, authUser.keycloakSub);
this.usersService.update(id, updateUserDto, authUser.keycloakSub),
);
} }
/** /**
@@ -224,7 +224,7 @@ export class UsersController {
const user = await this.authService.syncUserFromToken(authUser); const user = await this.authService.syncUserFromToken(authUser);
// Delete the user's account // 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', description: 'Invalid or missing JWT token',
}) })
@HttpCode(204) @HttpCode(204)
delete( async delete(
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): void { ): Promise<void> {
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`); 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 { ApiProperty } from '@nestjs/swagger';
import { User as PrismaUser } from '@prisma/client';
/** /**
* User entity representing a user in the system. * User entity representing a user in the system.
* Users are synced from Keycloak via OIDC authentication. * 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({ @ApiProperty({
description: 'Internal unique identifier', description: 'Internal unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
}) })
id: string; id: string;
/**
* Keycloak subject identifier (unique per user in Keycloak)
*/
@ApiProperty({ @ApiProperty({
description: 'Keycloak subject identifier from the JWT token', description: 'Keycloak subject identifier from the JWT token',
example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe', example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe',
}) })
keycloakSub: string; keycloakSub: string;
/**
* User's display name
*/
@ApiProperty({ @ApiProperty({
description: "User's display name", description: "User's display name",
example: 'John Doe', example: 'John Doe',
}) })
name: string; name: string;
/**
* User's email address
*/
@ApiProperty({ @ApiProperty({
description: "User's email address", description: "User's email address",
example: 'john.doe@example.com', example: 'john.doe@example.com',
}) })
email: string; email: string;
/**
* User's preferred username from Keycloak
*/
@ApiProperty({ @ApiProperty({
description: "User's preferred username from Keycloak", description: "User's preferred username from Keycloak",
example: 'johndoe', example: 'johndoe',
required: false, required: false,
nullable: true,
}) })
username?: string; username: string | null;
/**
* URL to user's profile picture
*/
@ApiProperty({ @ApiProperty({
description: "URL to user's profile picture", description: "URL to user's profile picture",
example: 'https://example.com/avatars/johndoe.jpg', example: 'https://example.com/avatars/johndoe.jpg',
required: false, required: false,
nullable: true,
}) })
picture?: string; picture: string | null;
/**
* User's roles from Keycloak
*/
@ApiProperty({ @ApiProperty({
description: "User's roles from Keycloak", description: "User's roles from Keycloak",
example: ['user', 'premium'], example: ['user', 'premium'],
type: [String], type: [String],
required: false, isArray: true,
}) })
roles?: string[]; roles: string[];
/**
* Timestamp when the user was first created in the system
*/
@ApiProperty({ @ApiProperty({
description: 'Timestamp when the user was first created', description: 'Timestamp when the user was first created',
example: '2024-01-15T10:30:00.000Z', example: '2024-01-15T10:30:00.000Z',
}) })
createdAt: Date; createdAt: Date;
/**
* Timestamp when the user profile was last updated
*/
@ApiProperty({ @ApiProperty({
description: 'Timestamp when the user was last updated', description: 'Timestamp when the user was last updated',
example: '2024-01-20T14:45:00.000Z', example: '2024-01-20T14:45:00.000Z',
}) })
updatedAt: Date; updatedAt: Date;
/**
* Timestamp of last login
*/
@ApiProperty({ @ApiProperty({
description: 'Timestamp of last login', description: 'Timestamp of last login',
example: '2024-01-20T14:45:00.000Z', example: '2024-01-20T14:45:00.000Z',
required: false, required: false,
nullable: true,
}) })
lastLoginAt?: Date; lastLoginAt: Date | null;
} }

View File

@@ -1,17 +1,52 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { PrismaService } from '../database/prisma.service';
import type { UpdateUserDto } from './dto/update-user.dto'; import type { UpdateUserDto } from './dto/update-user.dto';
import type { User } from '@prisma/client';
describe('UsersService', () => { describe('UsersService', () => {
let service: 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 () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [UsersService], providers: [
UsersService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile(); }).compile();
service = module.get<UsersService>(UsersService); service = module.get<UsersService>(UsersService);
prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks before each test
jest.clearAllMocks();
}); });
it('should be defined', () => { it('should be defined', () => {
@@ -19,7 +54,7 @@ describe('UsersService', () => {
}); });
describe('createFromToken', () => { 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 = { const tokenData = {
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'john@example.com', email: 'john@example.com',
@@ -29,259 +64,258 @@ describe('UsersService', () => {
roles: ['user', 'premium'], 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).toBeDefined();
expect(user.id).toBeDefined(); expect(user.id).toBeDefined();
expect(typeof user.id).toBe('string');
expect(user.keycloakSub).toBe('f:realm:user123'); expect(user.keycloakSub).toBe('f:realm:user123');
expect(user.email).toBe('john@example.com'); expect(user.email).toBe('john@example.com');
expect(user.name).toBe('John Doe'); expect(user.name).toBe('John Doe');
expect(user.username).toBe('johndoe'); expect(user.username).toBe('johndoe');
expect(user.picture).toBe('https://example.com/avatar.jpg'); expect(user.picture).toBe('https://example.com/avatar.jpg');
expect(user.roles).toEqual(['user', 'premium']); expect(user.roles).toEqual(['user', 'premium']);
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.updatedAt).toBeInstanceOf(Date); expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
expect(user.lastLoginAt).toBeInstanceOf(Date); 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 = { const tokenData = {
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'john@example.com', email: 'john@example.com',
name: 'John Doe', name: 'John Doe',
}; };
const user1 = service.createFromToken(tokenData); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
const user2 = service.createFromToken(tokenData);
expect(user1.id).toBe(user2.id); const user = await service.createFromToken(tokenData);
expect(user1.keycloakSub).toBe(user2.keycloakSub);
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', () => { describe('findByKeycloakSub', () => {
it('should return the user if found by keycloakSub', () => { it('should return a user by keycloakSub', async () => {
const createdUser = service.createFromToken({ mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
keycloakSub: 'f:realm:user123',
email: 'test@example.com', const user = await service.findByKeycloakSub('f:realm:user123');
name: 'Test User',
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(); const user = await service.findByKeycloakSub('nonexistent');
expect(user?.id).toBe(createdUser.id);
expect(user?.keycloakSub).toBe('f:realm:user123');
});
it('should return null if user not found by keycloakSub', () => {
const user = service.findByKeycloakSub('non-existent-sub');
expect(user).toBeNull(); expect(user).toBeNull();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: 'nonexistent' },
});
}); });
}); });
describe('findOne', () => { describe('findOne', () => {
it('should return the user if found by ID', () => { it('should return a user by ID', async () => {
const createdUser = service.createFromToken({ mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
keycloakSub: 'f:realm:user123',
email: 'test@example.com', const user = await service.findOne(mockUser.id);
name: 'Test User',
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); await expect(service.findOne(nonexistentId)).rejects.toThrow(
});
it('should throw NotFoundException if user not found by ID', () => {
expect(() => service.findOne('non-existent-id')).toThrow(
NotFoundException, NotFoundException,
); );
expect(() => service.findOne('non-existent-id')).toThrow( await expect(service.findOne(nonexistentId)).rejects.toThrow(
'User with ID non-existent-id not found', `User with ID ${nonexistentId} not found`,
); );
}); });
}); });
describe('updateFromToken', () => { describe('updateFromToken', () => {
it('should update user data from token and set lastLoginAt', async () => { it('should update user from token data', async () => {
const createdUser = service.createFromToken({ const updateData = {
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', {
email: 'updated@example.com', 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'); it('should throw NotFoundException if user not found', async () => {
expect(updatedUser.name).toBe('Original Name'); // unchanged mockPrismaService.user.findUnique.mockResolvedValue(null);
expect(updatedUser.username).toBe('original'); // unchanged
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', () => { describe('update', () => {
it('should update user profile when user updates their own profile', () => { it('should update user profile', async () => {
const createdUser = service.createFromToken({ const updateDto: UpdateUserDto = {
keycloakSub: 'f:realm:user123', name: 'Updated Name',
email: 'test@example.com',
name: 'Old Name',
});
const updateUserDto: UpdateUserDto = {
name: 'New Name',
}; };
const updatedUser = service.update( const updatedUser = { ...mockUser, name: updateDto.name };
createdUser.id, mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
updateUserDto, mockPrismaService.user.update.mockResolvedValue(updatedUser);
'f:realm:user123',
const user = await service.update(
mockUser.id,
updateDto,
mockUser.keycloakSub,
); );
expect(updatedUser.id).toBe(createdUser.id); expect(user.name).toBe(updateDto.name);
expect(updatedUser.name).toBe('New 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', () => { it('should throw ForbiddenException if user tries to update someone else', async () => {
const createdUser = service.createFromToken({ const updateDto: UpdateUserDto = {
keycloakSub: 'f:realm:user123', name: 'Hacker Name',
email: 'test@example.com', };
name: 'Test User',
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(() => mockPrismaService.user.findUnique.mockResolvedValue(null);
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
).toThrow(ForbiddenException);
expect(() => await expect(
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'), service.update('nonexistent', updateDto, 'any-keycloak-sub'),
).toThrow('You can only update your own profile'); ).rejects.toThrow(NotFoundException);
});
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);
}); });
}); });
describe('delete', () => { describe('delete', () => {
it('should delete user when user deletes their own account', () => { it('should delete user account', async () => {
const createdUser = service.createFromToken({ mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
keycloakSub: 'f:realm:user123', mockPrismaService.user.delete.mockResolvedValue(mockUser);
email: 'delete@example.com',
name: 'To Delete', 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', () => { it('should throw NotFoundException if user not found', async () => {
const createdUser = service.createFromToken({ mockPrismaService.user.findUnique.mockResolvedValue(null);
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
});
expect(() => await expect(
service.delete(createdUser.id, 'f:realm:differentuser'), service.delete('nonexistent', 'any-keycloak-sub'),
).toThrow(ForbiddenException); ).rejects.toThrow(NotFoundException);
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);
}); });
}); });
}); });

View File

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