prisma with psotgresql
This commit is contained in:
15
.env.example
15
.env.example
@@ -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
2
.gitignore
vendored
@@ -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
219
README.md
@@ -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`
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -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
839
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,6 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- '@scarf/scarf'
|
||||||
|
- prisma
|
||||||
|
- unrs-resolver
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
18
prisma/migrations/20251123020958_init_users/migration.sql
Normal file
18
prisma/migrations/20251123020958_init_users/migration.sql
Normal 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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
47
prisma/schema.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/database/database.module.ts
Normal file
17
src/database/database.module.ts
Normal 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 {}
|
||||||
134
src/database/prisma.service.ts
Normal file
134
src/database/prisma.service.ts
Normal 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();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
|
|
||||||
expect(user).toBeDefined();
|
|
||||||
expect(user?.id).toBe(createdUser.id);
|
|
||||||
expect(user?.keycloakSub).toBe('f:realm:user123');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if user not found by keycloakSub', () => {
|
it('should return null if user not found', async () => {
|
||||||
const user = service.findByKeycloakSub('non-existent-sub');
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const user = await service.findByKeycloakSub('nonexistent');
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
expect(user).toEqual(createdUser);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if user not found by ID', () => {
|
it('should throw NotFoundException if user not found', async () => {
|
||||||
expect(() => service.findOne('non-existent-id')).toThrow(
|
const nonexistentId = 'nonexistent-id';
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.findOne(nonexistentId)).rejects.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'),
|
||||||
|
};
|
||||||
|
|
||||||
expect(updatedUser.email).toBe('updated@example.com');
|
const updatedUser = { ...mockUser, ...updateData };
|
||||||
expect(updatedUser.name).toBe('Original Name'); // unchanged
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
expect(updatedUser.username).toBe('original'); // unchanged
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
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', () => {
|
|
||||||
const createdUser = service.createFromToken({
|
|
||||||
keycloakSub: 'f:realm:user123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
name: 'Test User',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateUserDto: UpdateUserDto = { name: 'New Name' };
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
|
|
||||||
).toThrow(ForbiddenException);
|
|
||||||
|
|
||||||
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', () => {
|
it('should throw ForbiddenException if user tries to update someone else', async () => {
|
||||||
const updateUserDto: UpdateUserDto = { name: 'New Name' };
|
const updateDto: UpdateUserDto = {
|
||||||
|
name: 'Hacker Name',
|
||||||
|
};
|
||||||
|
|
||||||
expect(() =>
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
service.update('non-existent-id', updateUserDto, 'f:realm:user123'),
|
|
||||||
).toThrow(NotFoundException);
|
await expect(
|
||||||
|
service.update(mockUser.id, updateDto, 'different-keycloak-sub'),
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update user profile with empty name', () => {
|
it('should throw NotFoundException if user not found', async () => {
|
||||||
const createdUser = service.createFromToken({
|
const updateDto: UpdateUserDto = {
|
||||||
keycloakSub: 'f:realm:user123',
|
name: 'Test',
|
||||||
email: 'test@example.com',
|
};
|
||||||
name: 'Old Name',
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateUserDto: UpdateUserDto = { name: '' };
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
const updatedUser = service.update(
|
await expect(
|
||||||
createdUser.id,
|
service.update('nonexistent', updateDto, 'any-keycloak-sub'),
|
||||||
updateUserDto,
|
).rejects.toThrow(NotFoundException);
|
||||||
'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');
|
|
||||||
|
|
||||||
expect(() => service.findOne(createdUser.id)).toThrow(NotFoundException);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw ForbiddenException when user tries to delete another user', () => {
|
it('should throw ForbiddenException if user tries to delete someone else', async () => {
|
||||||
const createdUser = service.createFromToken({
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
keycloakSub: 'f:realm:user123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
name: 'Test User',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() =>
|
await expect(
|
||||||
service.delete(createdUser.id, 'f:realm:differentuser'),
|
service.delete(mockUser.id, 'different-keycloak-sub'),
|
||||||
).toThrow(ForbiddenException);
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
|
||||||
expect(() =>
|
expect(mockPrismaService.user.delete).not.toHaveBeenCalled();
|
||||||
service.delete(createdUser.id, 'f:realm:differentuser'),
|
|
||||||
).toThrow('You can only delete your own account');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if user not found', () => {
|
it('should throw NotFoundException if user not found', async () => {
|
||||||
expect(() =>
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
service.delete('non-existent-id', 'f:realm:user123'),
|
|
||||||
).toThrow(NotFoundException);
|
await expect(
|
||||||
|
service.delete('nonexistent', 'any-keycloak-sub'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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})`,
|
||||||
|
|||||||
Reference in New Issue
Block a user