diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..de6af12 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# FantasyRealm Online Character Manager - Environment Variables +# This file is a template. Run ./scripts/install.sh to generate .env with secure passwords. + +# PostgreSQL Configuration +POSTGRES_USER=fantasyrealm +POSTGRES_PASSWORD=__POSTGRES_PASSWORD__ +POSTGRES_DB=fantasyrealm + +# MongoDB Configuration +MONGO_USER=fantasyrealm +MONGO_PASSWORD=__MONGO_PASSWORD__ +MONGO_DB=fantasyrealm_logs + +# pgAdmin Configuration +PGADMIN_EMAIL=contact@fantasy-realm.com +PGADMIN_PASSWORD=__PGADMIN_PASSWORD__ + +# Frontend URL (used for email links) +FRONTEND_URL=https://fantasy-realm.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 818f9be..27baf00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,37 @@ on: pull_request: branches: [main, develop] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Detect file changes + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + backend: + - 'src/backend/**' + - '.github/workflows/ci.yml' + frontend: + - 'src/frontend/**' + - '.github/workflows/ci.yml' + backend: name: Backend (.NET) + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -18,40 +46,31 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Check if backend exists - id: check - run: | - if find . -name "*.sln" -o -name "*.csproj" 2>/dev/null | grep -q .; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - working-directory: src/backend - - name: Setup .NET - if: steps.check.outputs.exists == 'true' uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('src/backend/**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- + - name: Restore dependencies - if: steps.check.outputs.exists == 'true' run: dotnet restore - name: Build - if: steps.check.outputs.exists == 'true' run: dotnet build --no-restore --configuration Release - name: Test - if: steps.check.outputs.exists == 'true' run: dotnet test --no-build --configuration Release --verbosity normal --filter "Category!=Email" - - name: Skip (no backend yet) - if: steps.check.outputs.exists == 'false' - run: echo "Backend not yet implemented - skipping" - frontend: name: Frontend (React) + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' }} runs-on: ubuntu-latest defaults: run: @@ -61,18 +80,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Check if frontend exists - id: check - run: | - if [ -f "package.json" ]; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - working-directory: src/frontend - - name: Setup Node.js - if: steps.check.outputs.exists == 'true' uses: actions/setup-node@v4 with: node-version: '20' @@ -80,25 +88,30 @@ jobs: cache-dependency-path: src/frontend/package-lock.json - name: Install dependencies - if: steps.check.outputs.exists == 'true' run: npm ci - name: Lint - if: steps.check.outputs.exists == 'true' run: npm run lint - name: Type check - if: steps.check.outputs.exists == 'true' run: npm run type-check - name: Build - if: steps.check.outputs.exists == 'true' run: npm run build - name: Test - if: steps.check.outputs.exists == 'true' run: npm run test -- --passWithNoTests - - name: Skip (no frontend yet) - if: steps.check.outputs.exists == 'false' - run: echo "Frontend not yet implemented - skipping" + ci-success: + name: CI Success + needs: [changes, backend, frontend] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + if [[ "${{ needs.backend.result }}" == "failure" || "${{ needs.frontend.result }}" == "failure" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "CI passed successfully" diff --git a/README.md b/README.md index 5f2d81e..2a9081f 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,11 @@ Application web permettant aux joueurs de : | Couche | Technologie | |--------|-------------| -| Frontend | React 18 + TypeScript + Vite | +| Frontend | React 19 + TypeScript + Vite | | Backend | .NET 8 (ASP.NET Core Web API) | -| BDD Relationnelle | PostgreSQL | -| BDD NoSQL | MongoDB (logs d'activité) | +| BDD Relationnelle | PostgreSQL 18 | +| BDD NoSQL | MongoDB 8.0 (logs d'activité) | +| Conteneurisation | Docker Compose | ## Structure du projet @@ -36,58 +37,90 @@ fantasyrealm-character-manager/ ## Prérequis -- [Node.js](https://nodejs.org/) 20 LTS -- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 -- [PostgreSQL](https://www.postgresql.org/download/) 16 -- [MongoDB](https://www.mongodb.com/try/download) 7.0 +- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (pour l'environnement local) +- [Node.js](https://nodejs.org/) 20 LTS (pour le développement frontend) +- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 (optionnel, pour le développement backend hors Docker) -## Installation - -### 1. Cloner le repository +## Installation rapide (Docker) ```bash -git clone https://github.com/votre-username/fantasyrealm-character-manager.git +# Cloner le repository +git clone https://github.com/pierrick-fonquerne/fantasyrealm-character-manager.git cd fantasyrealm-character-manager + +# Lancer l'installation automatique +./scripts/install.sh ``` -### 2. Backend (.NET) +Le script va : +1. Vérifier que Docker est installé et démarré +2. Générer automatiquement des mots de passe sécurisés dans `.env` +3. Démarrer PostgreSQL 18, MongoDB 8.0 et l'API .NET +4. Exécuter les migrations SQL + +**Services disponibles après installation :** + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:5173 | +| API | http://localhost:5000 | +| Swagger | http://localhost:5000/swagger | +| pgAdmin | http://localhost:5050 | +| PostgreSQL | localhost:5432 | +| MongoDB | localhost:27017 | + +## Scripts utilitaires ```bash -cd src/backend -dotnet restore -cp appsettings.Development.example.json appsettings.Development.json -# Configurer les connexions BDD dans appsettings.Development.json -dotnet run --project src/FantasyRealm.Api +./scripts/start.sh # Démarrer l'environnement +./scripts/stop.sh # Arrêter l'environnement +./scripts/logs.sh # Voir les logs (tous les services) +./scripts/logs.sh api # Voir les logs de l'API +./scripts/migrate.sh # Exécuter les nouvelles migrations SQL +./scripts/reset-db.sh # Réinitialiser les bases de données ``` -L'API sera disponible sur `http://localhost:5000` - -### 3. Frontend (React) +## Développement Frontend ```bash cd src/frontend npm install -cp .env.example .env.local -# Configurer l'URL de l'API dans .env.local npm run dev ``` L'application sera disponible sur `http://localhost:5173` -### 4. Base de données +## Installation manuelle (sans Docker) + +
+Cliquer pour voir les instructions manuelles + +### Prérequis + +- [PostgreSQL](https://www.postgresql.org/download/) 18 +- [MongoDB](https://www.mongodb.com/try/download) 8.0 + +### Backend (.NET) + +```bash +cd src/backend +dotnet restore +# Configurer les connexions BDD dans appsettings.Development.json +dotnet run --project src/FantasyRealm.Api +``` + +### Base de données ```bash # PostgreSQL - Créer la base de données createdb -U postgres fantasyrealm # Exécuter les scripts SQL -psql -U postgres -d fantasyrealm -f database/sql/001_create_tables.sql -psql -U postgres -d fantasyrealm -f database/sql/002_seed_data.sql - -# MongoDB - Initialiser les collections -mongosh fantasyrealm database/mongodb/init.js +psql -U postgres -d fantasyrealm -f database/sql/001_initial_schema.sql ``` +
+ ## Commandes utiles ### Frontend diff --git a/database/sql/.gitkeep b/database/sql/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/sql/001_create_tables.sql b/database/sql/001_create_tables.sql index cfee90e..934127c 100644 --- a/database/sql/001_create_tables.sql +++ b/database/sql/001_create_tables.sql @@ -1,100 +1,105 @@ -- ============================================================================ --- FantasyRealm Character Manager - Database Schema --- PostgreSQL 15+ +-- FantasyRealm Online Character Manager - Database Schema +-- PostgreSQL 18+ -- ============================================================================ -- ============================================================================ --- TABLE: role +-- TABLE: roles -- User roles for authorization (user, employee, admin) -- ============================================================================ -CREATE TABLE role ( +CREATE TABLE roles ( id SERIAL PRIMARY KEY, label VARCHAR(50) NOT NULL UNIQUE ); +-- Seed default roles +INSERT INTO roles (label) VALUES ('User'), ('Employee'), ('Admin'); + -- ============================================================================ --- TABLE: user +-- TABLE: users -- Registered users of the application -- ============================================================================ -CREATE TABLE "user" ( +CREATE TABLE users ( id SERIAL PRIMARY KEY, pseudo VARCHAR(50) NOT NULL UNIQUE, - email VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, must_change_password BOOLEAN NOT NULL DEFAULT FALSE, - role_id INTEGER NOT NULL REFERENCES role(id) + role_id INTEGER NOT NULL REFERENCES roles(id) ); -CREATE INDEX idx_user_email ON "user"(email); -CREATE INDEX idx_user_pseudo ON "user"(pseudo); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_pseudo ON users(pseudo); +CREATE INDEX idx_users_role_id ON users(role_id); -- ============================================================================ --- TABLE: character +-- TABLE: characters -- Player characters created by users -- ============================================================================ -CREATE TABLE character ( +CREATE TABLE characters ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL, - gender VARCHAR(20) NOT NULL CHECK (gender IN ('male', 'female')), + gender VARCHAR(20) NOT NULL, skin_color VARCHAR(7) NOT NULL, eye_color VARCHAR(7) NOT NULL, hair_color VARCHAR(7) NOT NULL, eye_shape VARCHAR(50) NOT NULL, nose_shape VARCHAR(50) NOT NULL, mouth_shape VARCHAR(50) NOT NULL, - image TEXT, is_shared BOOLEAN NOT NULL DEFAULT FALSE, is_authorized BOOLEAN NOT NULL DEFAULT FALSE, - user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + image BYTEA, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT uq_character_name_per_user UNIQUE (name, user_id) + CONSTRAINT uq_characters_name_per_user UNIQUE (name, user_id) ); -CREATE INDEX idx_character_user_id ON character(user_id); +CREATE INDEX idx_characters_user_id ON characters(user_id); +CREATE INDEX idx_characters_gallery ON characters(is_shared, is_authorized) WHERE is_shared = TRUE AND is_authorized = TRUE; -- ============================================================================ --- TABLE: article +-- TABLE: articles -- Customization items (clothing, armor, weapons, accessories) -- ============================================================================ -CREATE TABLE article ( +CREATE TABLE articles ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, - type VARCHAR(20) NOT NULL CHECK (type IN ('clothing', 'armor', 'weapon', 'accessory')), - image TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE + is_active BOOLEAN NOT NULL DEFAULT TRUE, + image BYTEA, + type VARCHAR(20) NOT NULL ); -CREATE INDEX idx_article_type ON article(type); -CREATE INDEX idx_article_is_active ON article(is_active); +CREATE INDEX idx_articles_type ON articles(type); +CREATE INDEX idx_articles_active_type ON articles(type) WHERE is_active = TRUE; -- ============================================================================ --- TABLE: character_article +-- TABLE: character_articles -- Many-to-many relationship between characters and equipped articles -- ============================================================================ -CREATE TABLE character_article ( - character_id INTEGER NOT NULL REFERENCES character(id) ON DELETE CASCADE, - article_id INTEGER NOT NULL REFERENCES article(id) ON DELETE CASCADE, +CREATE TABLE character_articles ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, PRIMARY KEY (character_id, article_id) ); -- ============================================================================ --- TABLE: comment +-- TABLE: comments -- User reviews on shared characters -- ============================================================================ -CREATE TABLE comment ( +CREATE TABLE comments ( id SERIAL PRIMARY KEY, rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), text TEXT NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved')), commented_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - character_id INTEGER NOT NULL REFERENCES character(id) ON DELETE CASCADE, - author_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - CONSTRAINT uq_one_comment_per_user_per_character UNIQUE (character_id, author_id) + CONSTRAINT uq_comments_one_per_user_per_character UNIQUE (character_id, author_id) ); -CREATE INDEX idx_comment_character_id ON comment(character_id); -CREATE INDEX idx_comment_author_id ON comment(author_id); -CREATE INDEX idx_comment_status ON comment(status); +CREATE INDEX idx_comments_character_id ON comments(character_id); +CREATE INDEX idx_comments_author_id ON comments(author_id); +CREATE INDEX idx_comments_pending ON comments(character_id) WHERE status = 'pending'; diff --git a/database/sql/002_add_user_audit_columns.sql b/database/sql/002_add_user_audit_columns.sql new file mode 100644 index 0000000..c5c984e --- /dev/null +++ b/database/sql/002_add_user_audit_columns.sql @@ -0,0 +1,22 @@ +-- ============================================================================ +-- Migration: Add audit columns to users table +-- ============================================================================ + +-- Add created_at and updated_at columns for audit trail +ALTER TABLE users + ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Create trigger to auto-update updated_at on row modification +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/database/sql/002_seed_data.sql b/database/sql/002_seed_data.sql deleted file mode 100644 index 66c4834..0000000 --- a/database/sql/002_seed_data.sql +++ /dev/null @@ -1,117 +0,0 @@ --- ============================================================================ --- FantasyRealm Character Manager - Seed Data --- PostgreSQL 15+ --- ============================================================================ - --- ============================================================================ --- ROLES --- ============================================================================ -INSERT INTO role (label) VALUES - ('user'), - ('employee'), - ('admin'); - --- ============================================================================ --- ADMIN USER --- Password: Admin123! (BCrypt hash) --- ============================================================================ -INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES - ('admin', 'admin@fantasyrealm.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'admin')); - --- ============================================================================ --- EMPLOYEE USER (for testing moderation) --- Password: Employee123! --- ============================================================================ -INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES - ('moderator', 'moderator@fantasyrealm.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'employee')); - --- ============================================================================ --- TEST USERS --- Password: Test123! --- ============================================================================ -INSERT INTO "user" (pseudo, email, password_hash, role_id) VALUES - ('player_one', 'player1@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user')), - ('dragon_slayer', 'player2@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user')), - ('mystic_mage', 'player3@test.com', '$2a$11$rBVcQ4z4.vYZ3mQlJXqKaeKxNJqX5vYxJqKqYQ5ZJ8z5X5X5X5X5q', (SELECT id FROM role WHERE label = 'user')); - --- ============================================================================ --- ARTICLES - Clothing --- ============================================================================ -INSERT INTO article (name, type, image) VALUES - ('Apprentice Robe', 'clothing', NULL), - ('Leather Tunic', 'clothing', NULL), - ('Noble Vest', 'clothing', NULL), - ('Traveler Cloak', 'clothing', NULL), - ('Silk Dress', 'clothing', NULL); - --- ============================================================================ --- ARTICLES - Armor --- ============================================================================ -INSERT INTO article (name, type, image) VALUES - ('Iron Chestplate', 'armor', NULL), - ('Steel Helmet', 'armor', NULL), - ('Dragon Scale Armor', 'armor', NULL), - ('Mithril Chainmail', 'armor', NULL), - ('Guardian Shield', 'armor', NULL); - --- ============================================================================ --- ARTICLES - Weapons --- ============================================================================ -INSERT INTO article (name, type, image) VALUES - ('Iron Sword', 'weapon', NULL), - ('Oak Staff', 'weapon', NULL), - ('Elven Bow', 'weapon', NULL), - ('Battle Axe', 'weapon', NULL), - ('Enchanted Dagger', 'weapon', NULL); - --- ============================================================================ --- ARTICLES - Accessories --- ============================================================================ -INSERT INTO article (name, type, image) VALUES - ('Ruby Amulet', 'accessory', NULL), - ('Silver Ring', 'accessory', NULL), - ('Leather Belt', 'accessory', NULL), - ('Explorer Backpack', 'accessory', NULL), - ('Golden Crown', 'accessory', NULL); - --- ============================================================================ --- TEST CHARACTERS (authorized and shared for gallery demo) --- ============================================================================ -INSERT INTO character (name, gender, skin_color, eye_color, hair_color, eye_shape, nose_shape, mouth_shape, is_shared, is_authorized, user_id) VALUES - ('Thorin', 'male', '#E8BEAC', '#4A90D9', '#2C1810', 'almond', 'aquiline', 'thin', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'player_one')), - ('Elara', 'female', '#F5DEB3', '#50C878', '#FFD700', 'round', 'straight', 'full', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer')), - ('Zephyr', 'male', '#8D5524', '#8B4513', '#000000', 'narrow', 'wide', 'medium', TRUE, TRUE, (SELECT id FROM "user" WHERE pseudo = 'mystic_mage')); - --- ============================================================================ --- TEST CHARACTER (pending moderation) --- ============================================================================ -INSERT INTO character (name, gender, skin_color, eye_color, hair_color, eye_shape, nose_shape, mouth_shape, is_shared, is_authorized, user_id) VALUES - ('Shadow', 'male', '#3D2314', '#FF0000', '#1C1C1C', 'narrow', 'pointed', 'thin', FALSE, FALSE, (SELECT id FROM "user" WHERE pseudo = 'player_one')); - --- ============================================================================ --- EQUIP ARTICLES TO CHARACTERS --- ============================================================================ -INSERT INTO character_article (character_id, article_id) VALUES - ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Iron Chestplate')), - ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Iron Sword')), - ((SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM article WHERE name = 'Leather Belt')), - ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Silk Dress')), - ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Elven Bow')), - ((SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM article WHERE name = 'Ruby Amulet')), - ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Apprentice Robe')), - ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Oak Staff')), - ((SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM article WHERE name = 'Silver Ring')); - --- ============================================================================ --- TEST COMMENTS (approved) --- ============================================================================ -INSERT INTO comment (rating, text, status, character_id, author_id) VALUES - (5, 'Amazing warrior design! Love the armor combo.', 'approved', (SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer')), - (4, 'Beautiful elven character, very elegant!', 'approved', (SELECT id FROM character WHERE name = 'Elara'), (SELECT id FROM "user" WHERE pseudo = 'player_one')), - (5, 'The mage aesthetic is perfect!', 'approved', (SELECT id FROM character WHERE name = 'Zephyr'), (SELECT id FROM "user" WHERE pseudo = 'dragon_slayer')); - --- ============================================================================ --- TEST COMMENT (pending moderation) --- ============================================================================ -INSERT INTO comment (rating, text, status, character_id, author_id) VALUES - (3, 'Nice but could use more accessories.', 'pending', (SELECT id FROM character WHERE name = 'Thorin'), (SELECT id FROM "user" WHERE pseudo = 'mystic_mage')); diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/charte-graphique.pdf b/docs/charte-graphique.pdf new file mode 100644 index 0000000..1ce6bd0 Binary files /dev/null and b/docs/charte-graphique.pdf differ diff --git a/infra/.gitkeep b/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..fc22cbd --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,88 @@ +services: + postgres: + image: postgres:18-alpine + container_name: fantasyrealm-postgres + ports: + - "5432:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-fantasyrealm} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-fantasyrealm} + volumes: + - postgres_data:/var/lib/postgresql/data + - ../database/sql:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fantasyrealm}"] + interval: 5s + timeout: 5s + retries: 5 + + mongodb: + image: mongo:8.0 + container_name: fantasyrealm-mongodb + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-fantasyrealm} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGO_DB:-fantasyrealm_logs} + volumes: + - mongo_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 5 + + pgadmin: + image: dpage/pgadmin4:latest + container_name: fantasyrealm-pgadmin + ports: + - "5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-contact@fantasy-realm.com} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD} + PGADMIN_CONFIG_SERVER_MODE: "False" + PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" + volumes: + - pgadmin_data:/var/lib/pgadmin + - ./pgadmin/servers.json:/pgadmin4/servers.json:ro + depends_on: + postgres: + condition: service_healthy + + api: + build: + context: ../src/backend + dockerfile: Dockerfile + container_name: fantasyrealm-api + ports: + - "5000:8080" + environment: + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__PostgreSQL: "Host=postgres;Port=5432;Database=${POSTGRES_DB:-fantasyrealm};Username=${POSTGRES_USER:-fantasyrealm};Password=${POSTGRES_PASSWORD}" + ConnectionStrings__MongoDB: "mongodb://${MONGO_USER:-fantasyrealm}:${MONGO_PASSWORD}@mongodb:27017/${MONGO_DB:-fantasyrealm_logs}?authSource=admin" + Email__Password: ${EMAIL_PASSWORD} + Email__BaseUrl: ${FRONTEND_URL:-http://localhost:5173} + depends_on: + postgres: + condition: service_healthy + mongodb: + condition: service_healthy + + frontend: + build: + context: ../src/frontend + dockerfile: Dockerfile + container_name: fantasyrealm-frontend + ports: + - "5173:80" + environment: + VITE_API_URL: http://localhost:5000 + depends_on: + - api + +volumes: + postgres_data: + mongo_data: + pgadmin_data: diff --git a/infra/pgadmin/servers.json b/infra/pgadmin/servers.json new file mode 100644 index 0000000..c1c0063 --- /dev/null +++ b/infra/pgadmin/servers.json @@ -0,0 +1,13 @@ +{ + "Servers": { + "1": { + "Name": "FantasyRealm Local", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "fantasyrealm", + "Username": "fantasyrealm", + "SSLMode": "prefer" + } + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..6f71a67 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# FantasyRealm Online Character Manager - Installation Script +# This script sets up the local development environment with Docker Compose. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "==========================================" +echo " FantasyRealm Online Character Manager" +echo " Local Development Setup" +echo "==========================================" +echo "" + +# Step 1: Check Docker is installed +echo "[1/5] Checking Docker installation..." +if ! command -v docker &> /dev/null; then + echo "❌ Docker is not installed. Please install Docker Desktop first." + echo " https://www.docker.com/products/docker-desktop/" + exit 1 +fi + +if ! docker info &> /dev/null; then + echo "❌ Docker is not running. Please start Docker Desktop." + exit 1 +fi + +echo "✓ Docker is installed and running" + +# Step 2: Generate .env file with secure passwords +echo "" +echo "[2/5] Configuring environment variables..." + +if [ ! -f "$ROOT_DIR/.env" ]; then + # Generate random passwords (16 characters, URL-safe) + POSTGRES_PWD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) + MONGO_PWD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) + PGADMIN_PWD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) + + # Create .env from template + sed -e "s/__POSTGRES_PASSWORD__/$POSTGRES_PWD/" \ + -e "s/__MONGO_PASSWORD__/$MONGO_PWD/" \ + -e "s/__PGADMIN_PASSWORD__/$PGADMIN_PWD/" \ + "$ROOT_DIR/.env.example" > "$ROOT_DIR/.env" + + echo "✓ Generated .env with secure passwords" +else + echo "✓ .env already exists (skipped)" +fi + +# Step 3: Start Docker containers +echo "" +echo "[3/5] Starting Docker containers..." +docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" up -d --build + +# Step 4: Wait for services to be healthy +echo "" +echo "[4/5] Waiting for services to be ready..." + +echo " Waiting for PostgreSQL..." +timeout=60 +elapsed=0 +while ! docker exec fantasyrealm-postgres pg_isready -U fantasyrealm &> /dev/null; do + if [ $elapsed -ge $timeout ]; then + echo "❌ PostgreSQL failed to start within ${timeout}s" + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done +echo " ✓ PostgreSQL is ready" + +echo " Waiting for MongoDB..." +elapsed=0 +while ! docker exec fantasyrealm-mongodb mongosh --eval "db.adminCommand('ping')" &> /dev/null; do + if [ $elapsed -ge $timeout ]; then + echo "❌ MongoDB failed to start within ${timeout}s" + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done +echo " ✓ MongoDB is ready" + +# Step 5: Run database migrations +echo "" +echo "[5/5] Running database migrations..." +"$SCRIPT_DIR/migrate.sh" + +# Display summary +echo "" +echo "==========================================" +echo " ✓ Installation Complete!" +echo "==========================================" +echo "" +echo "Services running:" +echo " • Frontend: http://localhost:5173" +echo " • API: http://localhost:5000" +echo " • Swagger: http://localhost:5000/swagger" +echo " • pgAdmin: http://localhost:5050" +echo " • PostgreSQL: localhost:5432" +echo " • MongoDB: localhost:27017" +echo "" +echo "pgAdmin credentials:" +echo " • Email: contact@fantasy-realm.com" +echo " • Password: (see .env file)" +echo "" +echo "Useful commands:" +echo " ./scripts/start.sh Start the environment" +echo " ./scripts/stop.sh Stop the environment" +echo " ./scripts/logs.sh View logs" +echo " ./scripts/migrate.sh Run new migrations" +echo " ./scripts/reset-db.sh Reset databases" +echo "" diff --git a/scripts/logs.sh b/scripts/logs.sh new file mode 100644 index 0000000..89ae644 --- /dev/null +++ b/scripts/logs.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# FantasyRealm Online Character Manager - Logs Script +# Shows logs from all containers or a specific service. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +SERVICE=$1 + +if [ -z "$SERVICE" ]; then + echo "Showing logs for all services (Ctrl+C to exit)..." + docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" logs -f +else + echo "Showing logs for $SERVICE (Ctrl+C to exit)..." + docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" logs -f "$SERVICE" +fi diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100644 index 0000000..a74e437 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +# FantasyRealm Online Character Manager - Database Migration Script +# This script executes SQL migrations that haven't been run yet. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +SQL_DIR="$ROOT_DIR/database/sql" + +# Load environment variables from .env file +if [ -f "$ROOT_DIR/.env" ]; then + set -a + source "$ROOT_DIR/.env" + set +a +fi + +POSTGRES_USER="${POSTGRES_USER:-fantasyrealm}" +POSTGRES_DB="${POSTGRES_DB:-fantasyrealm}" + +echo "Running database migrations..." + +# Create __migrations table if it doesn't exist +docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" fantasyrealm-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c " +CREATE TABLE IF NOT EXISTS __migrations ( + id SERIAL PRIMARY KEY, + script_name VARCHAR(255) NOT NULL UNIQUE, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + checksum VARCHAR(64) +);" + +# Get list of already executed migrations +EXECUTED=$(docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" fantasyrealm-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t -A -c "SELECT script_name FROM __migrations ORDER BY script_name;") + +# Count migrations to apply +MIGRATIONS_APPLIED=0 + +# Process each SQL file in order +for SQL_FILE in $(ls "$SQL_DIR"/*.sql 2>/dev/null | sort); do + FILENAME=$(basename "$SQL_FILE") + + # Skip if already executed + if echo "$EXECUTED" | grep -q "^${FILENAME}$"; then + echo " ⏭ $FILENAME (already applied)" + continue + fi + + # Calculate checksum + CHECKSUM=$(sha256sum "$SQL_FILE" | cut -d ' ' -f 1) + + echo " ▶ Applying $FILENAME..." + + # Execute the SQL file + docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" fantasyrealm-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$SQL_FILE" + + # Record the migration + docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" fantasyrealm-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c \ + "INSERT INTO __migrations (script_name, checksum) VALUES ('$FILENAME', '$CHECKSUM');" + + echo " ✓ $FILENAME applied" + MIGRATIONS_APPLIED=$((MIGRATIONS_APPLIED + 1)) +done + +if [ $MIGRATIONS_APPLIED -eq 0 ]; then + echo "✓ Database is up to date (no new migrations)" +else + echo "✓ Applied $MIGRATIONS_APPLIED migration(s)" +fi diff --git a/scripts/reset-db.sh b/scripts/reset-db.sh new file mode 100644 index 0000000..8278549 --- /dev/null +++ b/scripts/reset-db.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# FantasyRealm Character Manager - Reset Database Script +# WARNING: This will delete all data and re-run migrations. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "⚠️ WARNING: This will delete ALL data from PostgreSQL and MongoDB!" +echo "" +read -p "Are you sure? (yes/no): " CONFIRM + +if [ "$CONFIRM" != "yes" ]; then + echo "Aborted." + exit 0 +fi + +echo "" +echo "Resetting databases..." + +# Stop containers and remove volumes +docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" down -v + +# Restart containers +docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" up -d + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL..." +timeout=60 +elapsed=0 +while ! docker exec fantasyrealm-postgres pg_isready -U fantasyrealm &> /dev/null; do + if [ $elapsed -ge $timeout ]; then + echo "❌ PostgreSQL failed to start" + exit 1 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +# Run migrations +"$SCRIPT_DIR/migrate.sh" + +echo "" +echo "✓ Databases reset successfully" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..a72d884 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# FantasyRealm Character Manager - Start Script +# Starts the Docker containers. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "Starting FantasyRealm services..." + +if [ ! -f "$ROOT_DIR/.env" ]; then + echo "❌ .env file not found. Run ./scripts/install.sh first." + exit 1 +fi + +docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" up -d + +echo "" +echo "✓ Services started" +echo "" +echo " • API: http://localhost:5000" +echo " • PostgreSQL: localhost:5432" +echo " • MongoDB: localhost:27017" diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100644 index 0000000..6c1aefb --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# FantasyRealm Character Manager - Stop Script +# Stops the Docker containers without removing volumes. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "Stopping FantasyRealm services..." + +docker compose -f "$ROOT_DIR/infra/docker-compose.yml" --env-file "$ROOT_DIR/.env" down + +echo "✓ Services stopped" diff --git a/src/backend/.dockerignore b/src/backend/.dockerignore new file mode 100644 index 0000000..589cfb3 --- /dev/null +++ b/src/backend/.dockerignore @@ -0,0 +1,11 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +**/*.suo +**/node_modules/ +.git/ +.gitignore +*.md +Dockerfile* +docker-compose* diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile new file mode 100644 index 0000000..882482a --- /dev/null +++ b/src/backend/Dockerfile @@ -0,0 +1,33 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy solution and project files +COPY FantasyRealm.sln . +COPY src/FantasyRealm.Api/*.csproj src/FantasyRealm.Api/ +COPY src/FantasyRealm.Application/*.csproj src/FantasyRealm.Application/ +COPY src/FantasyRealm.Domain/*.csproj src/FantasyRealm.Domain/ +COPY src/FantasyRealm.Infrastructure/*.csproj src/FantasyRealm.Infrastructure/ +COPY tests/FantasyRealm.Tests.Unit/*.csproj tests/FantasyRealm.Tests.Unit/ +COPY tests/FantasyRealm.Tests.Integration/*.csproj tests/FantasyRealm.Tests.Integration/ + +# Restore dependencies +RUN dotnet restore + +# Copy source code +COPY . . + +# Build +RUN dotnet build -c Release --no-restore + +# Publish +RUN dotnet publish src/FantasyRealm.Api/FantasyRealm.Api.csproj -c Release -o /app/publish --no-build + +# Runtime image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish . + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "FantasyRealm.Api.dll"] diff --git a/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..f1ac787 --- /dev/null +++ b/src/backend/src/FantasyRealm.Api/Controllers/AuthController.cs @@ -0,0 +1,128 @@ +using System.IdentityModel.Tokens.Jwt; +using FantasyRealm.Application.DTOs; +using FantasyRealm.Application.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace FantasyRealm.Api.Controllers +{ + /// + /// Controller for authentication-related endpoints. + /// + [ApiController] + [Route("api/[controller]")] + public sealed class AuthController(IAuthService authService) : ControllerBase + { + /// + /// Registers a new user account. + /// + /// The registration details. + /// Cancellation token. + /// The created user information. + /// User successfully registered. + /// Invalid request data or password validation failed. + /// Email or pseudo already exists. + [HttpPost("register")] + [ProducesResponseType(typeof(RegisterResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + { + var result = await authService.RegisterAsync(request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 400, new { message = result.Error }); + } + + return CreatedAtAction(nameof(Register), new { id = result.Value!.Id }, result.Value); + } + + /// + /// Authenticates a user and returns a JWT token. + /// + /// The login credentials. + /// Cancellation token. + /// The JWT token and user information. + /// Login successful. + /// Invalid credentials. + /// Account suspended. + [HttpPost("login")] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken) + { + var result = await authService.LoginAsync(request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 401, new { message = result.Error }); + } + + return Ok(result.Value); + } + + /// + /// Initiates a password reset by generating and sending a temporary password. + /// + /// The email and pseudo for identity verification. + /// Cancellation token. + /// Success message. + /// Password reset email sent successfully. + /// Invalid request data. + /// Account suspended. + /// No account matches the provided email and pseudo. + [HttpPost("forgot-password")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken cancellationToken) + { + var result = await authService.ForgotPasswordAsync(request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 400, new { message = result.Error }); + } + + return Ok(new { message = "Un nouveau mot de passe a été envoyé à votre adresse email." }); + } + + /// + /// Changes the password for the authenticated user. + /// + /// The current and new password details. + /// Cancellation token. + /// A new JWT token upon successful password change. + /// Password changed successfully. + /// Invalid request data or password validation failed. + /// Not authenticated or current password is incorrect. + /// Account suspended. + [HttpPost("change-password")] + [Authorize] + [ProducesResponseType(typeof(ChangePasswordResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken) + { + var userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Token invalide." }); + } + + var result = await authService.ChangePasswordAsync(userId, request, cancellationToken); + + if (result.IsFailure) + { + return StatusCode(result.ErrorCode ?? 400, new { message = result.Error }); + } + + return Ok(result.Value); + } + } +} diff --git a/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.csproj b/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.csproj index 5145224..1ac2ea7 100644 --- a/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.csproj +++ b/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.http b/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.http index c7cca3f..c29de8a 100644 --- a/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.http +++ b/src/backend/src/FantasyRealm.Api/FantasyRealm.Api.http @@ -1,6 +1,3 @@ @FantasyRealm.Api_HostAddress = http://localhost:5071 -GET {{FantasyRealm.Api_HostAddress}}/weatherforecast/ -Accept: application/json - ### diff --git a/src/backend/src/FantasyRealm.Api/Program.cs b/src/backend/src/FantasyRealm.Api/Program.cs index 51a3c71..ff66c59 100644 --- a/src/backend/src/FantasyRealm.Api/Program.cs +++ b/src/backend/src/FantasyRealm.Api/Program.cs @@ -1,6 +1,15 @@ +using System.Text; +using FantasyRealm.Infrastructure; +using FantasyRealm.Infrastructure.Security; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + namespace FantasyRealm.Api { - public static class Program + /// + /// Application entry point. + /// + public partial class Program { private static void Main(string[] args) { @@ -23,9 +32,30 @@ private static void Main(string[] args) }); }); - // TODO: Add JWT Authentication (FRO-1) - // TODO: Add Application services (FRO-15+) - // TODO: Add Infrastructure services (FRO-17) + // Infrastructure services (Database, Email, Auth) + builder.Services.AddInfrastructure(builder.Configuration); + + // JWT Authentication configuration + var jwtSettings = builder.Configuration.GetSection(JwtSettings.SectionName).Get()!; + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings.Issuer, + ValidAudience = jwtSettings.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) + }; + }); var app = builder.Build(); @@ -41,6 +71,7 @@ private static void Main(string[] args) app.UseHttpsRedirection(); app.UseCors("AllowFrontend"); + app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/src/backend/src/FantasyRealm.Api/appsettings.json b/src/backend/src/FantasyRealm.Api/appsettings.json index d22ca03..c365572 100644 --- a/src/backend/src/FantasyRealm.Api/appsettings.json +++ b/src/backend/src/FantasyRealm.Api/appsettings.json @@ -20,6 +20,13 @@ "Username": "noreply@fantasy-realm.com", "Password": "", "FromAddress": "noreply@fantasy-realm.com", - "FromName": "FantasyRealm" + "FromName": "FantasyRealm", + "BaseUrl": "http://localhost:5173" + }, + "Jwt": { + "Secret": "CHANGE_ME_IN_PRODUCTION_THIS_IS_A_DEV_SECRET_KEY_MIN_32_CHARS", + "Issuer": "FantasyRealm", + "Audience": "FantasyRealmUsers", + "ExpirationHours": 24 } } diff --git a/src/backend/src/FantasyRealm.Application/Common/Result.cs b/src/backend/src/FantasyRealm.Application/Common/Result.cs new file mode 100644 index 0000000..34fa08c --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Common/Result.cs @@ -0,0 +1,43 @@ +namespace FantasyRealm.Application.Common +{ + /// + /// Represents the result of an operation that can either succeed with a value or fail with an error. + /// + /// The type of the value returned on success. + public sealed class Result + { + private Result(T? value, string? error, int? errorCode, bool isSuccess) + { + Value = value; + Error = error; + ErrorCode = errorCode; + IsSuccess = isSuccess; + } + + public bool IsSuccess { get; } + + public bool IsFailure => !IsSuccess; + + public T? Value { get; } + + public string? Error { get; } + + public int? ErrorCode { get; } + + /// + /// Creates a successful result with the specified value. + /// + public static Result Success(T value) + { + return new Result(value, null, null, true); + } + + /// + /// Creates a failed result with the specified error message and HTTP status code. + /// + public static Result Failure(string error, int errorCode = 400) + { + return new Result(default, error, errorCode, false); + } + } +} diff --git a/src/backend/src/FantasyRealm.Application/Common/Unit.cs b/src/backend/src/FantasyRealm.Application/Common/Unit.cs new file mode 100644 index 0000000..8d3c5c7 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Common/Unit.cs @@ -0,0 +1,26 @@ +namespace FantasyRealm.Application.Common +{ + /// + /// Represents a void type, since void is not a valid type in C# for generics. + /// Used as the result type for operations that complete without returning a value. + /// + public readonly struct Unit : IEquatable + { + /// + /// Gets the single instance of Unit. + /// + public static readonly Unit Value = new(); + + public bool Equals(Unit other) => true; + + public override bool Equals(object? obj) => obj is Unit; + + public override int GetHashCode() => 0; + + public static bool operator ==(Unit left, Unit right) => true; + + public static bool operator !=(Unit left, Unit right) => false; + + public override string ToString() => "()"; + } +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs new file mode 100644 index 0000000..c093096 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for changing user password. + /// + public sealed record ChangePasswordRequest( + [Required(ErrorMessage = "Le mot de passe actuel est requis.")] + string CurrentPassword, + + [Required(ErrorMessage = "Le nouveau mot de passe est requis.")] + string NewPassword, + + [Required(ErrorMessage = "La confirmation du mot de passe est requise.")] + string ConfirmNewPassword + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs new file mode 100644 index 0000000..4e7f0b3 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ChangePasswordResponse.cs @@ -0,0 +1,11 @@ +namespace FantasyRealm.Application.DTOs +{ + /// + /// Response payload for successful password change. + /// + public sealed record ChangePasswordResponse( + string Token, + DateTime ExpiresAt, + UserInfo User + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs new file mode 100644 index 0000000..c21d505 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/ForgotPasswordRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for password reset functionality. + /// Requires both email and pseudo for identity verification. + /// + public sealed record ForgotPasswordRequest( + [Required(ErrorMessage = "L'email est requis.")] + [EmailAddress(ErrorMessage = "Le format de l'email est invalide.")] + [MaxLength(255, ErrorMessage = "L'email ne peut pas dépasser 255 caractères.")] + string Email, + + [Required(ErrorMessage = "Le pseudo est requis.")] + [MinLength(3, ErrorMessage = "Le pseudo doit contenir au moins 3 caractères.")] + [MaxLength(30, ErrorMessage = "Le pseudo ne peut pas dépasser 30 caractères.")] + string Pseudo + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/LoginRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/LoginRequest.cs new file mode 100644 index 0000000..167cd46 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/LoginRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for user login. + /// + public sealed record LoginRequest( + [Required(ErrorMessage = "L'email est requis.")] + [EmailAddress(ErrorMessage = "Le format de l'email est invalide.")] + string Email, + + [Required(ErrorMessage = "Le mot de passe est requis.")] + string Password + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/LoginResponse.cs b/src/backend/src/FantasyRealm.Application/DTOs/LoginResponse.cs new file mode 100644 index 0000000..c66a0da --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/LoginResponse.cs @@ -0,0 +1,12 @@ +namespace FantasyRealm.Application.DTOs +{ + /// + /// Response payload for successful login. + /// + public sealed record LoginResponse( + string Token, + DateTime ExpiresAt, + UserInfo User, + bool MustChangePassword + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/RegisterRequest.cs b/src/backend/src/FantasyRealm.Application/DTOs/RegisterRequest.cs new file mode 100644 index 0000000..4154a34 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/RegisterRequest.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace FantasyRealm.Application.DTOs +{ + /// + /// Request payload for user registration. + /// + public sealed record RegisterRequest( + [Required(ErrorMessage = "L'email est requis.")] + [EmailAddress(ErrorMessage = "Le format de l'email est invalide.")] + [MaxLength(255, ErrorMessage = "L'email ne peut pas dépasser 255 caractères.")] + string Email, + + [Required(ErrorMessage = "Le pseudo est requis.")] + [MinLength(3, ErrorMessage = "Le pseudo doit contenir au moins 3 caractères.")] + [MaxLength(30, ErrorMessage = "Le pseudo ne peut pas dépasser 30 caractères.")] + string Pseudo, + + [Required(ErrorMessage = "Le mot de passe est requis.")] + string Password, + + [Required(ErrorMessage = "La confirmation du mot de passe est requise.")] + string ConfirmPassword + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/RegisterResponse.cs b/src/backend/src/FantasyRealm.Application/DTOs/RegisterResponse.cs new file mode 100644 index 0000000..fdf50b7 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/RegisterResponse.cs @@ -0,0 +1,13 @@ +namespace FantasyRealm.Application.DTOs +{ + /// + /// Response payload after successful user registration. + /// + public sealed record RegisterResponse( + int Id, + string Email, + string Pseudo, + string Role, + DateTime CreatedAt + ); +} diff --git a/src/backend/src/FantasyRealm.Application/DTOs/UserInfo.cs b/src/backend/src/FantasyRealm.Application/DTOs/UserInfo.cs new file mode 100644 index 0000000..af800e6 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/DTOs/UserInfo.cs @@ -0,0 +1,12 @@ +namespace FantasyRealm.Application.DTOs +{ + /// + /// Basic user information returned in authentication responses. + /// + public sealed record UserInfo( + int Id, + string Email, + string Pseudo, + string Role + ); +} diff --git a/src/backend/src/FantasyRealm.Application/FantasyRealm.Application.csproj b/src/backend/src/FantasyRealm.Application/FantasyRealm.Application.csproj index 27854ef..4d779cd 100644 --- a/src/backend/src/FantasyRealm.Application/FantasyRealm.Application.csproj +++ b/src/backend/src/FantasyRealm.Application/FantasyRealm.Application.csproj @@ -6,6 +6,7 @@ + diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs new file mode 100644 index 0000000..abb94b3 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IAuthService.cs @@ -0,0 +1,44 @@ +using FantasyRealm.Application.Common; +using FantasyRealm.Application.DTOs; + +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Service interface for authentication operations. + /// + public interface IAuthService + { + /// + /// Registers a new user account. + /// + /// The registration request containing user details. + /// Cancellation token. + /// A result containing the registered user info or an error. + Task> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default); + + /// + /// Authenticates a user and returns a JWT token. + /// + /// The login request containing credentials. + /// Cancellation token. + /// A result containing the login response with token or an error. + Task> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + + /// + /// Initiates a password reset by generating a temporary password and sending it via email. + /// + /// The forgot password request containing email and pseudo. + /// Cancellation token. + /// A result indicating success or an error. + Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default); + + /// + /// Changes the password for an authenticated user. + /// + /// The ID of the authenticated user. + /// The change password request containing current and new passwords. + /// Cancellation token. + /// A result containing the new token or an error. + Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs index c53e400..1757662 100644 --- a/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IEmailService.cs @@ -24,6 +24,16 @@ public interface IEmailService /// A task representing the asynchronous operation. Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default); + /// + /// Sends an email containing a temporary password. + /// + /// The recipient's email address. + /// The user's display name. + /// The generated temporary password. + /// A cancellation token. + /// A task representing the asynchronous operation. + Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default); + /// /// Sends a notification when a character has been approved by an employee. /// diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IJwtService.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IJwtService.cs new file mode 100644 index 0000000..86f7622 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IJwtService.cs @@ -0,0 +1,24 @@ +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Service interface for JWT token operations. + /// + public interface IJwtService + { + /// + /// Generates a JWT token for the specified user. + /// + /// The user's unique identifier. + /// The user's email address. + /// The user's display name. + /// The user's role. + /// A signed JWT token string. + string GenerateToken(int userId, string email, string pseudo, string role); + + /// + /// Gets the expiration date for a newly generated token. + /// + /// The UTC datetime when the token will expire. + DateTime GetExpirationDate(); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs new file mode 100644 index 0000000..f6f60dc --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordGenerator.cs @@ -0,0 +1,15 @@ +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Service interface for generating secure passwords. + /// + public interface IPasswordGenerator + { + /// + /// Generates a cryptographically secure random password that meets CNIL requirements. + /// + /// The desired password length (minimum 12). + /// A secure password containing uppercase, lowercase, digits, and special characters. + string GenerateSecurePassword(int length = 16); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordHasher.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordHasher.cs new file mode 100644 index 0000000..292022b --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IPasswordHasher.cs @@ -0,0 +1,23 @@ +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Provides password hashing and verification functionality. + /// + public interface IPasswordHasher + { + /// + /// Hashes a plain text password using a secure algorithm. + /// + /// The plain text password to hash. + /// The hashed password with embedded salt. + string Hash(string password); + + /// + /// Verifies a plain text password against a hashed password. + /// + /// The plain text password to verify. + /// The hashed password to compare against. + /// True if the password matches the hash; otherwise, false. + bool Verify(string password, string hash); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs new file mode 100644 index 0000000..81ca503 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Interfaces/IUserRepository.cs @@ -0,0 +1,65 @@ +using FantasyRealm.Domain.Entities; + +namespace FantasyRealm.Application.Interfaces +{ + /// + /// Repository interface for User entity data access operations. + /// + public interface IUserRepository + { + /// + /// Checks if a user with the specified email already exists. + /// + Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default); + + /// + /// Checks if a user with the specified pseudo already exists. + /// + Task ExistsByPseudoAsync(string pseudo, CancellationToken cancellationToken = default); + + /// + /// Creates a new user in the database. + /// + /// The created user with generated Id. + Task CreateAsync(User user, CancellationToken cancellationToken = default); + + /// + /// Retrieves a role by its label. + /// + Task GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default); + + /// + /// Retrieves a user by email address, including their role. + /// + /// The email address to search for (case-insensitive). + /// Cancellation token. + /// The user with their role, or null if not found. + Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default); + + /// + /// Retrieves a user by ID, including their role. + /// + /// The user ID. + /// Cancellation token. + /// The user with their role, or null if not found. + Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Retrieves a user by email and pseudo combination, including their role. + /// Used for password reset verification. + /// + /// The email address to search for (case-insensitive). + /// The pseudo to match. + /// Cancellation token. + /// The user with their role, or null if not found. + Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default); + + /// + /// Updates an existing user in the database. + /// + /// The user entity with updated values. + /// Cancellation token. + /// The updated user entity. + Task UpdateAsync(User user, CancellationToken cancellationToken = default); + } +} diff --git a/src/backend/src/FantasyRealm.Application/Services/.gitkeep b/src/backend/src/FantasyRealm.Application/Services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/backend/src/FantasyRealm.Application/Services/AuthService.cs b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs new file mode 100644 index 0000000..e83881a --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Services/AuthService.cs @@ -0,0 +1,236 @@ +using FantasyRealm.Application.Common; +using FantasyRealm.Application.DTOs; +using FantasyRealm.Application.Interfaces; +using FantasyRealm.Application.Validators; +using FantasyRealm.Domain.Entities; +using Microsoft.Extensions.Logging; + +namespace FantasyRealm.Application.Services +{ + /// + /// Service implementation for authentication operations. + /// + public sealed class AuthService( + IUserRepository userRepository, + IPasswordHasher passwordHasher, + IPasswordGenerator passwordGenerator, + IEmailService emailService, + IJwtService jwtService, + ILogger logger) : IAuthService + { + private const string DefaultRole = "User"; + private const string InvalidCredentialsMessage = "Identifiants incorrects."; + private const string AccountSuspendedMessage = "Votre compte a été suspendu."; + private const string UserNotFoundMessage = "Aucun compte ne correspond à ces informations."; + + /// + public async Task> RegisterAsync(RegisterRequest request, CancellationToken cancellationToken = default) + { + if (request.Password != request.ConfirmPassword) + { + return Result.Failure("Les mots de passe ne correspondent pas.", 400); + } + + var passwordValidation = PasswordValidator.Validate(request.Password); + if (!passwordValidation.IsValid) + { + var errorMessage = string.Join(" ", passwordValidation.Errors); + return Result.Failure(errorMessage, 400); + } + + var emailExists = await userRepository.ExistsByEmailAsync(request.Email, cancellationToken); + if (emailExists) + { + return Result.Failure("Cette adresse email est déjà utilisée.", 409); + } + + var pseudoExists = await userRepository.ExistsByPseudoAsync(request.Pseudo, cancellationToken); + if (pseudoExists) + { + return Result.Failure("Ce pseudo est déjà utilisé.", 409); + } + + var role = await userRepository.GetRoleByLabelAsync(DefaultRole, cancellationToken); + if (role is null) + { + return Result.Failure("Configuration error: default role not found.", 500); + } + + var user = new User + { + Email = request.Email.ToLowerInvariant().Trim(), + Pseudo = request.Pseudo.Trim(), + PasswordHash = passwordHasher.Hash(request.Password), + RoleId = role.Id, + IsSuspended = false, + MustChangePassword = false + }; + + var createdUser = await userRepository.CreateAsync(user, cancellationToken); + + _ = Task.Run(async () => + { + try + { + await emailService.SendWelcomeEmailAsync(createdUser.Email, createdUser.Pseudo, CancellationToken.None); + logger.LogInformation("Welcome email sent to {Email}", createdUser.Email); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send welcome email to {Email}", createdUser.Email); + } + }, CancellationToken.None); + + return Result.Success(new RegisterResponse( + createdUser.Id, + createdUser.Email, + createdUser.Pseudo, + DefaultRole, + createdUser.CreatedAt + )); + } + + /// + public async Task> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + { + var normalizedEmail = request.Email.ToLowerInvariant().Trim(); + + var user = await userRepository.GetByEmailWithRoleAsync(normalizedEmail, cancellationToken); + + if (user is null) + { + logger.LogWarning("Login failed: user not found for email {Email}", normalizedEmail); + return Result.Failure(InvalidCredentialsMessage, 401); + } + + if (!passwordHasher.Verify(request.Password, user.PasswordHash)) + { + logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id); + return Result.Failure(InvalidCredentialsMessage, 401); + } + + if (user.IsSuspended) + { + logger.LogWarning("Login failed: account suspended for user {UserId}", user.Id); + return Result.Failure(AccountSuspendedMessage, 403); + } + + var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label); + var expiresAt = jwtService.GetExpirationDate(); + + logger.LogInformation("Login successful for user {UserId} ({Email})", user.Id, user.Email); + + var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label); + + return Result.Success(new LoginResponse( + token, + expiresAt, + userInfo, + user.MustChangePassword + )); + } + + /// + public async Task> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken cancellationToken = default) + { + var normalizedEmail = request.Email.ToLowerInvariant().Trim(); + var normalizedPseudo = request.Pseudo.Trim(); + + var user = await userRepository.GetByEmailAndPseudoAsync(normalizedEmail, normalizedPseudo, cancellationToken); + + if (user is null) + { + logger.LogWarning("Password reset failed: no user found for email {Email} and pseudo {Pseudo}", normalizedEmail, normalizedPseudo); + return Result.Failure(UserNotFoundMessage, 404); + } + + if (user.IsSuspended) + { + logger.LogWarning("Password reset failed: account suspended for user {UserId}", user.Id); + return Result.Failure(AccountSuspendedMessage, 403); + } + + var temporaryPassword = passwordGenerator.GenerateSecurePassword(); + user.PasswordHash = passwordHasher.Hash(temporaryPassword); + user.MustChangePassword = true; + + await userRepository.UpdateAsync(user, cancellationToken); + + _ = Task.Run(async () => + { + try + { + await emailService.SendTemporaryPasswordEmailAsync(user.Email, user.Pseudo, temporaryPassword, CancellationToken.None); + logger.LogInformation("Temporary password email sent to {Email}", user.Email); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send temporary password email to {Email}", user.Email); + } + }, CancellationToken.None); + + logger.LogInformation("Password reset successful for user {UserId} ({Email})", user.Id, user.Email); + + return Result.Success(Unit.Value); + } + + /// + public async Task> ChangePasswordAsync(int userId, ChangePasswordRequest request, CancellationToken cancellationToken = default) + { + var user = await userRepository.GetByIdWithRoleAsync(userId, cancellationToken); + + if (user is null) + { + logger.LogWarning("Change password failed: user not found for ID {UserId}", userId); + return Result.Failure(InvalidCredentialsMessage, 401); + } + + if (user.IsSuspended) + { + logger.LogWarning("Change password failed: account suspended for user {UserId}", userId); + return Result.Failure(AccountSuspendedMessage, 403); + } + + if (!passwordHasher.Verify(request.CurrentPassword, user.PasswordHash)) + { + logger.LogWarning("Change password failed: invalid current password for user {UserId}", userId); + return Result.Failure("Le mot de passe actuel est incorrect.", 401); + } + + if (request.NewPassword != request.ConfirmNewPassword) + { + return Result.Failure("Les nouveaux mots de passe ne correspondent pas.", 400); + } + + var passwordValidation = PasswordValidator.Validate(request.NewPassword); + if (!passwordValidation.IsValid) + { + var errorMessage = string.Join(" ", passwordValidation.Errors); + return Result.Failure(errorMessage, 400); + } + + if (passwordHasher.Verify(request.NewPassword, user.PasswordHash)) + { + return Result.Failure("Le nouveau mot de passe doit être différent de l'ancien.", 400); + } + + user.PasswordHash = passwordHasher.Hash(request.NewPassword); + user.MustChangePassword = false; + + await userRepository.UpdateAsync(user, cancellationToken); + + var token = jwtService.GenerateToken(user.Id, user.Email, user.Pseudo, user.Role.Label); + var expiresAt = jwtService.GetExpirationDate(); + + logger.LogInformation("Password changed successfully for user {UserId} ({Email})", user.Id, user.Email); + + var userInfo = new UserInfo(user.Id, user.Email, user.Pseudo, user.Role.Label); + + return Result.Success(new ChangePasswordResponse( + token, + expiresAt, + userInfo + )); + } + } +} diff --git a/src/backend/src/FantasyRealm.Application/Validators/PasswordValidationResult.cs b/src/backend/src/FantasyRealm.Application/Validators/PasswordValidationResult.cs new file mode 100644 index 0000000..e0c6aaa --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Validators/PasswordValidationResult.cs @@ -0,0 +1,25 @@ +namespace FantasyRealm.Application.Validators +{ + /// + /// Represents the result of a password validation check. + /// + public sealed record PasswordValidationResult( + bool IsValid, + int Score, + IReadOnlyList Errors + ) + { + /// + /// Password strength levels based on score. + /// + public string Strength => Score switch + { + 0 => "Très faible", + 1 => "Faible", + 2 => "Moyen", + 3 => "Fort", + >= 4 => "Très fort", + _ => "Inconnu" + }; + } +} diff --git a/src/backend/src/FantasyRealm.Application/Validators/PasswordValidator.cs b/src/backend/src/FantasyRealm.Application/Validators/PasswordValidator.cs new file mode 100644 index 0000000..344e849 --- /dev/null +++ b/src/backend/src/FantasyRealm.Application/Validators/PasswordValidator.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +namespace FantasyRealm.Application.Validators +{ + /// + /// Validates passwords according to CNIL recommendations. + /// + public static class PasswordValidator + { + private const int MinLength = 12; + + private static readonly Regex UppercaseRegex = new("[A-Z]", RegexOptions.Compiled); + private static readonly Regex LowercaseRegex = new("[a-z]", RegexOptions.Compiled); + private static readonly Regex DigitRegex = new("[0-9]", RegexOptions.Compiled); + private static readonly Regex SpecialCharRegex = new(@"[!@#$%^&*()_+\-=\[\]{}|;:',.<>?/\\]", RegexOptions.Compiled); + + /// + /// Validates a password against CNIL security requirements. + /// + /// The password to validate. + /// A result containing validation status, strength score, and any errors. + public static PasswordValidationResult Validate(string password) + { + var errors = new List(); + var score = 0; + + if (string.IsNullOrEmpty(password)) + { + errors.Add("Le mot de passe est requis."); + return new PasswordValidationResult(false, 0, errors); + } + + if (password.Length < MinLength) + { + errors.Add($"Le mot de passe doit contenir au moins {MinLength} caractères."); + } + else + { + score++; + if (password.Length >= 16) score++; + } + + if (!UppercaseRegex.IsMatch(password)) + { + errors.Add("Le mot de passe doit contenir au moins une majuscule."); + } + else + { + score++; + } + + if (!LowercaseRegex.IsMatch(password)) + { + errors.Add("Le mot de passe doit contenir au moins une minuscule."); + } + else + { + score++; + } + + if (!DigitRegex.IsMatch(password)) + { + errors.Add("Le mot de passe doit contenir au moins un chiffre."); + } + else + { + score++; + } + + if (!SpecialCharRegex.IsMatch(password)) + { + errors.Add("Le mot de passe doit contenir au moins un caractère spécial (!@#$%^&*()_+-=[]{}|;:',.<>?/)."); + } + else + { + score++; + } + + return new PasswordValidationResult(errors.Count == 0, score, errors); + } + } +} diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Article.cs b/src/backend/src/FantasyRealm.Domain/Entities/Article.cs index 34c1cec..9cc84eb 100644 --- a/src/backend/src/FantasyRealm.Domain/Entities/Article.cs +++ b/src/backend/src/FantasyRealm.Domain/Entities/Article.cs @@ -13,7 +13,7 @@ public class Article public ArticleType Type { get; set; } - public string? Image { get; set; } + public byte[]? Image { get; set; } public bool IsActive { get; set; } = true; diff --git a/src/backend/src/FantasyRealm.Domain/Entities/Character.cs b/src/backend/src/FantasyRealm.Domain/Entities/Character.cs index fc9f480..1234d1c 100644 --- a/src/backend/src/FantasyRealm.Domain/Entities/Character.cs +++ b/src/backend/src/FantasyRealm.Domain/Entities/Character.cs @@ -25,7 +25,7 @@ public class Character public string MouthShape { get; set; } = string.Empty; - public string? Image { get; set; } + public byte[]? Image { get; set; } public bool IsShared { get; set; } diff --git a/src/backend/src/FantasyRealm.Domain/Entities/User.cs b/src/backend/src/FantasyRealm.Domain/Entities/User.cs index db52e26..f826e8d 100644 --- a/src/backend/src/FantasyRealm.Domain/Entities/User.cs +++ b/src/backend/src/FantasyRealm.Domain/Entities/User.cs @@ -17,6 +17,10 @@ public class User public bool MustChangePassword { get; set; } + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } + public int RoleId { get; set; } public Role Role { get; set; } = null!; diff --git a/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep b/src/backend/src/FantasyRealm.Domain/Interfaces/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep b/src/backend/src/FantasyRealm.Infrastructure/Data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs index 56b9cd6..5d14120 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/DependencyInjection.cs @@ -1,6 +1,9 @@ using FantasyRealm.Application.Interfaces; +using FantasyRealm.Application.Services; using FantasyRealm.Infrastructure.Email; using FantasyRealm.Infrastructure.Persistence; +using FantasyRealm.Infrastructure.Repositories; +using FantasyRealm.Infrastructure.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -45,6 +48,14 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddSingleton(); services.AddScoped(); + services.Configure(configuration.GetSection(JwtSettings.SectionName)); + services.AddSingleton(); + + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + return services; } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs index 72dce71..3a84836 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs @@ -44,5 +44,10 @@ public class EmailSettings /// Gets or sets the sender display name. /// public string FromName { get; set; } = string.Empty; + + /// + /// Gets or sets the base URL for links in email templates. + /// + public string BaseUrl { get; set; } = string.Empty; } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs index e8e1ce0..dd5b275 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs @@ -4,30 +4,80 @@ namespace FantasyRealm.Infrastructure.Email { /// /// Provides HTML email templates for various notification types. + /// Uses a dark fantasy theme consistent with the FantasyRealm MMORPG aesthetic. /// public static class EmailTemplates { - private const string BaseUrl = "https://fantasy-realm.com"; + private const string DarkBg = "#0D0D0F"; // --dark-900 + private const string CardBg = "#121110"; // --dark-800 + private const string CardBorder = "#18181B"; // --dark-700 + private const string GoldPrimary = "#F59E0B"; // --gold-500 + private const string GoldLight = "#FBBF24"; // --gold-400 + private const string GoldDark = "#D97706"; // --gold-600 + private const string TextLight = "#E8E4DE"; // --cream-200 (text-primary) + private const string TextMuted = "#A8A29E"; // --cream-400 (text-muted) /// /// Generates a welcome email template for new users. /// /// The user's display name. + /// The base URL for links in the email. /// The HTML email body. - public static string GetWelcomeTemplate(string pseudo) + public static string GetWelcomeTemplate(string pseudo, string baseUrl) { - return WrapInLayout($@" -

Welcome to FantasyRealm, {Encode(pseudo)}!

-

Thank you for joining our community of adventurers.

-

You can now:

-
    -
  • Create and customize your characters
  • -
  • Equip them with weapons, armor, and accessories
  • -
  • Share your creations with the community
  • -
  • Comment and rate other players' characters
  • -
-

Start your adventure now!

- Create Your First Character + return WrapInLayout( + "Bienvenue dans FantasyRealm !", + $@" +
+
⚔️
+

+ Bienvenue, {Encode(pseudo)} ! +

+

+ Votre aventure commence maintenant +

+
+ +
+
+

+ Merci d'avoir rejoint la guilde des aventuriers de FantasyRealm Online. + Un monde de magie et d'aventures vous attend ! +

+ +
+

+ 🎮 Vous pouvez maintenant : +

+
    +
  • + + Créer et personnaliser vos personnages +
  • +
  • + + Les équiper d'armes, armures et accessoires légendaires +
  • +
  • + + Partager vos créations avec la communauté +
  • +
  • + + Découvrir les héros des autres joueurs +
  • +
+
+
+
+ +
+ {GetPrimaryButton("Créer mon premier personnage", $"{baseUrl}/characters/create", "🛡️")} +
+ +

+ Que la fortune guide vos pas, aventurier ! +

"); } @@ -36,17 +86,100 @@ public static string GetWelcomeTemplate(string pseudo) /// /// The user's display name. /// The password reset token. + /// The base URL for links in the email. /// The HTML email body. - public static string GetPasswordResetTemplate(string pseudo, string resetToken) + public static string GetPasswordResetTemplate(string pseudo, string resetToken, string baseUrl) { - var resetUrl = $"{BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; - return WrapInLayout($@" -

Password Reset Request

-

Hello {Encode(pseudo)},

-

We received a request to reset your password. Click the button below to create a new password:

- Reset Password -

This link will expire in 24 hours.

-

If you didn't request this password reset, you can safely ignore this email.

+ var resetUrl = $"{baseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}"; + return WrapInLayout( + "Réinitialisation de mot de passe", + $@" +
+
🔐
+

+ Réinitialisation de mot de passe +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Nous avons reçu une demande de réinitialisation de votre mot de passe. + Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe : +

+
+ +
+ {GetPrimaryButton("Réinitialiser mon mot de passe", resetUrl, "🔑")} +
+ +
+

+ ⏱️ Ce lien expirera dans 24 heures.
+ 🛡️ Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email. +

+
+ "); + } + + /// + /// Generates a temporary password email template. + /// + /// The user's display name. + /// The generated temporary password. + /// The base URL for links in the email. + /// The HTML email body. + public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword, string baseUrl) + { + return WrapInLayout( + "Nouveau mot de passe temporaire", + $@" +
+
🔐
+

+ Nouveau mot de passe temporaire +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Suite à votre demande, voici votre nouveau mot de passe temporaire : +

+
+ +
+

+ Votre mot de passe temporaire +

+

+ {Encode(temporaryPassword)} +

+
+ +
+

+ ⚠️ Important +

+

+ Vous devrez changer ce mot de passe lors de votre prochaine connexion. + Ce mot de passe est temporaire et ne devrait pas être réutilisé. +

+
+ +
+ {GetPrimaryButton("Se connecter", $"{baseUrl}/login", "🔑")} +
+ +
+

+ 🛡️ Si vous n'êtes pas à l'origine de cette demande, veuillez contacter notre support immédiatement. +

+
"); } @@ -55,15 +188,39 @@ public static string GetPasswordResetTemplate(string pseudo, string resetToken) /// /// The user's display name. /// The approved character's name. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCharacterApprovedTemplate(string pseudo, string characterName) + public static string GetCharacterApprovedTemplate(string pseudo, string characterName, string baseUrl) { - return WrapInLayout($@" -

Character Approved!

-

Great news, {Encode(pseudo)}!

-

Your character {Encode(characterName)} has been reviewed and approved by our moderation team.

-

Your character is now visible to the entire FantasyRealm community. Other players can view, comment, and rate your creation.

- View Your Characters + return WrapInLayout( + "Personnage approuvé !", + $@" +
+
🎉
+

+ Personnage approuvé ! +

+
+ +
+

+ Excellente nouvelle, {Encode(pseudo)} ! +

+

+ Votre personnage {Encode(characterName)} a été examiné + et approuvé par notre équipe de modération. +

+
+ +
+

+ ✨ Votre personnage est maintenant visible par toute la communauté FantasyRealm ! +

+
+ +
+ {GetPrimaryButton("Voir mes personnages", $"{baseUrl}/characters", "👥")} +
"); } @@ -73,16 +230,46 @@ public static string GetCharacterApprovedTemplate(string pseudo, string characte /// The user's display name. /// The rejected character's name. /// The rejection reason. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason) + public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason, string baseUrl) { - return WrapInLayout($@" -

Character Not Approved

-

Hello {Encode(pseudo)},

-

Unfortunately, your character {Encode(characterName)} was not approved for public display.

-

Reason: {Encode(reason)}

-

You can modify your character and submit it again for review.

- Edit Your Character + return WrapInLayout( + "Personnage non approuvé", + $@" +
+
📝
+

+ Personnage non approuvé +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Malheureusement, votre personnage {Encode(characterName)} + n'a pas été approuvé pour l'affichage public. +

+
+ +
+

+ ⚠️ Raison du refus : +

+

+ {Encode(reason)} +

+
+ +

+ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen. +

+ +
+ {GetPrimaryButton("Modifier mon personnage", $"{baseUrl}/characters", "✏️")} +
"); } @@ -91,15 +278,37 @@ public static string GetCharacterRejectedTemplate(string pseudo, string characte /// /// The user's display name. /// The character's name that was commented on. + /// The base URL for links in the email. /// The HTML email body. - public static string GetCommentApprovedTemplate(string pseudo, string characterName) + public static string GetCommentApprovedTemplate(string pseudo, string characterName, string baseUrl) { - return WrapInLayout($@" -

Comment Published!

-

Hello {Encode(pseudo)},

-

Your comment on the character {Encode(characterName)} has been approved and is now visible to the community.

-

Thank you for contributing to the FantasyRealm community!

- Browse the Gallery + return WrapInLayout( + "Commentaire publié !", + $@" +
+
💬
+

+ Commentaire publié ! +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Votre commentaire sur le personnage {Encode(characterName)} + a été approuvé et est maintenant visible par la communauté. +

+
+ +

+ 🙏 Merci de contribuer à la communauté FantasyRealm ! +

+ +
+ {GetPrimaryButton("Explorer la galerie", $"{baseUrl}/gallery", "🖼️")} +
"); } @@ -112,12 +321,38 @@ public static string GetCommentApprovedTemplate(string pseudo, string characterN /// The HTML email body. public static string GetCommentRejectedTemplate(string pseudo, string characterName, string reason) { - return WrapInLayout($@" -

Comment Not Approved

-

Hello {Encode(pseudo)},

-

Your comment on the character {Encode(characterName)} was not approved for publication.

-

Reason: {Encode(reason)}

-

Please review our community guidelines and feel free to submit a new comment.

+ return WrapInLayout( + "Commentaire non approuvé", + $@" +
+
💬
+

+ Commentaire non approuvé +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Votre commentaire sur le personnage {Encode(characterName)} + n'a pas été approuvé pour publication. +

+
+ +
+

+ ⚠️ Raison du refus : +

+

+ {Encode(reason)} +

+
+ +

+ Veuillez consulter nos règles de communauté. Vous pouvez soumettre un nouveau commentaire respectant ces règles. +

"); } @@ -129,13 +364,40 @@ public static string GetCommentRejectedTemplate(string pseudo, string characterN /// The HTML email body. public static string GetAccountSuspendedTemplate(string pseudo, string reason) { - return WrapInLayout($@" -

Account Suspended

-

Hello {Encode(pseudo)},

-

Your FantasyRealm account has been suspended due to a violation of our terms of service.

-

Reason: {Encode(reason)}

-

If you believe this is an error, please contact our support team.

-

You will not be able to access your account or characters until this matter is resolved.

+ return WrapInLayout( + "Compte suspendu", + $@" +
+
🚫
+

+ Compte suspendu +

+
+ +
+

+ Bonjour {Encode(pseudo)}, +

+

+ Votre compte FantasyRealm a été suspendu en raison d'une violation de nos conditions d'utilisation. +

+
+ +
+

+ ⚠️ Raison de la suspension : +

+

+ {Encode(reason)} +

+
+ +
+

+ 📧 Si vous pensez qu'il s'agit d'une erreur, veuillez contacter notre équipe de support.
+ 🔒 Vous ne pourrez pas accéder à votre compte ni à vos personnages tant que cette situation n'aura pas été résolue. +

+
"); } @@ -144,95 +406,116 @@ private static string Encode(string value) return WebUtility.HtmlEncode(value); } - private static string WrapInLayout(string content) + private static string GetPrimaryButton(string text, string url, string icon = "") + { + var iconHtml = string.IsNullOrEmpty(icon) ? "" : $"{icon} "; + return $@" + + {iconHtml}{text} + "; + } + + private static string WrapInLayout(string title, string content) { return $@" - - - - - - FantasyRealm - - - -
-
-

FantasyRealm

-
- {content} -
-

© {DateTime.UtcNow.Year} FantasyRealm by PixelVerse Studios. All rights reserved.

-

This is an automated message. Please do not reply to this email.

-
-
- -"; + + + + + + {Encode(title)} - FantasyRealm + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + +
🏰 + + FANTASYREALM + +
+ + Character Manager + +
+
+
+ {content} +
+ + + + +
+ +
+ +

+ © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios +

+

+ Ceci est un message automatique. Merci de ne pas répondre à cet email. +

+
+
+ +
+ + + "; } } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs index 3f2b78b..f92aeb5 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs @@ -1,5 +1,4 @@ using FantasyRealm.Application.Interfaces; -using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,33 +9,24 @@ namespace FantasyRealm.Infrastructure.Email /// /// SMTP-based implementation of the email service using MailKit. /// - public class SmtpEmailService : IEmailService + /// + /// Initializes a new instance of the class. + /// + /// The email configuration settings. + /// The factory for creating SMTP clients. + /// The logger instance. + public class SmtpEmailService( + IOptions settings, + ISmtpClientFactory smtpClientFactory, + ILogger logger) : IEmailService { - private readonly EmailSettings _settings; - private readonly ISmtpClientFactory _smtpClientFactory; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The email configuration settings. - /// The factory for creating SMTP clients. - /// The logger instance. - public SmtpEmailService( - IOptions settings, - ISmtpClientFactory smtpClientFactory, - ILogger logger) - { - _settings = settings.Value; - _smtpClientFactory = smtpClientFactory; - _logger = logger; - } + private readonly EmailSettings _settings = settings.Value; /// public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, CancellationToken cancellationToken = default) { var subject = "Welcome to FantasyRealm!"; - var body = EmailTemplates.GetWelcomeTemplate(pseudo); + var body = EmailTemplates.GetWelcomeTemplate(pseudo, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -44,7 +34,15 @@ public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, Cancellat public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default) { var subject = "Reset your FantasyRealm password"; - var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken); + var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken, _settings.BaseUrl); + await SendEmailAsync(toEmail, subject, body, cancellationToken); + } + + /// + public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default) + { + var subject = "Votre nouveau mot de passe FantasyRealm"; + var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -52,7 +50,7 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default) { var subject = $"Your character {characterName} has been approved!"; - var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName); + var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -60,7 +58,7 @@ public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, string characterName, string reason, CancellationToken cancellationToken = default) { var subject = $"Your character {characterName} was not approved"; - var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason); + var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -68,7 +66,7 @@ public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, public async Task SendCommentApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default) { var subject = "Your comment has been approved!"; - var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName); + var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName, _settings.BaseUrl); await SendEmailAsync(toEmail, subject, body, cancellationToken); } @@ -103,7 +101,7 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod try { - using var client = _smtpClientFactory.Create(); + using var client = smtpClientFactory.Create(); var secureSocketOptions = _settings.UseSsl ? SecureSocketOptions.StartTls @@ -114,11 +112,11 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod await client.SendAsync(message, cancellationToken); await client.DisconnectAsync(true, cancellationToken); - _logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject); + logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject); + logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject); throw; } } diff --git a/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj b/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj index 7362e39..33c7cce 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj +++ b/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj @@ -6,12 +6,13 @@ - + + diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs new file mode 100644 index 0000000..fc8b4eb --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FantasyRealm.Infrastructure.Persistence.Converters +{ + /// + /// Converts DateTime values to and from UTC for PostgreSQL compatibility. + /// PostgreSQL with Npgsql requires DateTime values to have Kind=Utc for timestamp with time zone columns. + /// + public sealed class UtcDateTimeConverter : ValueConverter + { + public UtcDateTimeConverter() + : base( + v => v.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(v, DateTimeKind.Utc) + : v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)) + { + } + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs index 036d50a..cc1d952 100644 --- a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs +++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs @@ -1,6 +1,7 @@ using FantasyRealm.Domain.Entities; using FantasyRealm.Domain.Enums; using FantasyRealm.Infrastructure.Persistence.Conventions; +using FantasyRealm.Infrastructure.Persistence.Converters; using Microsoft.EntityFrameworkCore; namespace FantasyRealm.Infrastructure.Persistence @@ -22,6 +23,14 @@ public class FantasyRealmDbContext(DbContextOptions optio public DbSet Comments { get; set; } = null!; + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + + configurationBuilder.Properties() + .HaveConversion(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -52,10 +61,12 @@ private static void ConfigureUser(ModelBuilder modelBuilder) { entity.HasKey(e => e.Id); entity.Property(e => e.Pseudo).HasMaxLength(50).IsRequired(); - entity.Property(e => e.Email).HasMaxLength(100).IsRequired(); + entity.Property(e => e.Email).HasMaxLength(255).IsRequired(); entity.Property(e => e.PasswordHash).HasMaxLength(255).IsRequired(); entity.Property(e => e.IsSuspended).HasDefaultValue(false); entity.Property(e => e.MustChangePassword).HasDefaultValue(false); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP"); entity.HasIndex(e => e.Pseudo).IsUnique(); entity.HasIndex(e => e.Email).IsUnique(); diff --git a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..d90b85a --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,80 @@ +using FantasyRealm.Application.Interfaces; +using FantasyRealm.Domain.Entities; +using FantasyRealm.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FantasyRealm.Infrastructure.Repositories +{ + /// + /// Repository implementation for User entity data access operations. + /// + public sealed class UserRepository(FantasyRealmDbContext context) : IUserRepository + { + /// + public async Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default) + { + var normalizedEmail = email.ToLowerInvariant().Trim(); + return await context.Users + .AnyAsync(u => u.Email == normalizedEmail, cancellationToken); + } + + /// + public async Task ExistsByPseudoAsync(string pseudo, CancellationToken cancellationToken = default) + { + var normalizedPseudo = pseudo.Trim(); + return await context.Users + .AnyAsync(u => u.Pseudo == normalizedPseudo, cancellationToken); + } + + /// + public async Task CreateAsync(User user, CancellationToken cancellationToken = default) + { + context.Users.Add(user); + await context.SaveChangesAsync(cancellationToken); + return user; + } + + /// + public async Task GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default) + { + return await context.Roles + .FirstOrDefaultAsync(r => r.Label == label, cancellationToken); + } + + /// + public async Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default) + { + var normalizedEmail = email.ToLowerInvariant().Trim(); + return await context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken); + } + + /// + public async Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default) + { + return await context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + /// + public async Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default) + { + var normalizedEmail = email.ToLowerInvariant().Trim(); + var normalizedPseudo = pseudo.Trim(); + return await context.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Email == normalizedEmail && u.Pseudo == normalizedPseudo, cancellationToken); + } + + /// + public async Task UpdateAsync(User user, CancellationToken cancellationToken = default) + { + user.UpdatedAt = DateTime.UtcNow; + context.Users.Update(user); + await context.SaveChangesAsync(cancellationToken); + return user; + } + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs new file mode 100644 index 0000000..4754d69 --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs @@ -0,0 +1,73 @@ +using System.Security.Cryptography; +using System.Text; +using FantasyRealm.Application.Interfaces; +using Konscious.Security.Cryptography; + +namespace FantasyRealm.Infrastructure.Security +{ + /// + /// Argon2id password hasher implementation following OWASP recommendations. + /// + public sealed class Argon2PasswordHasher : IPasswordHasher + { + private const int SaltSize = 16; + private const int HashSize = 32; + private const int MemorySize = 65536; + private const int Iterations = 3; + private const int DegreeOfParallelism = 4; + + /// + public string Hash(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = HashPassword(password, salt); + + var result = new byte[SaltSize + HashSize]; + Buffer.BlockCopy(salt, 0, result, 0, SaltSize); + Buffer.BlockCopy(hash, 0, result, SaltSize, HashSize); + + return Convert.ToBase64String(result); + } + + /// + public bool Verify(string password, string hash) + { + try + { + var hashBytes = Convert.FromBase64String(hash); + + if (hashBytes.Length != SaltSize + HashSize) + { + return false; + } + + var salt = new byte[SaltSize]; + var storedHash = new byte[HashSize]; + + Buffer.BlockCopy(hashBytes, 0, salt, 0, SaltSize); + Buffer.BlockCopy(hashBytes, SaltSize, storedHash, 0, HashSize); + + var computedHash = HashPassword(password, salt); + + return CryptographicOperations.FixedTimeEquals(computedHash, storedHash); + } + catch + { + return false; + } + } + + private static byte[] HashPassword(string password, byte[] salt) + { + using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + MemorySize = MemorySize, + Iterations = Iterations, + DegreeOfParallelism = DegreeOfParallelism + }; + + return argon2.GetBytes(HashSize); + } + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs new file mode 100644 index 0000000..d2d80a4 --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs @@ -0,0 +1,54 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using FantasyRealm.Application.Interfaces; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace FantasyRealm.Infrastructure.Security +{ + /// + /// Service implementation for JWT token generation. + /// + public sealed class JwtService : IJwtService + { + private readonly JwtSettings _settings; + + public JwtService(IOptions settings) + { + _settings = settings.Value; + } + + /// + public string GenerateToken(int userId, string email, string pseudo, string role) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.Email, email), + new Claim("pseudo", pseudo), + new Claim(ClaimTypes.Role, role), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + var token = new JwtSecurityToken( + issuer: _settings.Issuer, + audience: _settings.Audience, + claims: claims, + expires: GetExpirationDate(), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + /// + public DateTime GetExpirationDate() + { + return DateTime.UtcNow.AddHours(_settings.ExpirationHours); + } + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs new file mode 100644 index 0000000..0bb0c63 --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs @@ -0,0 +1,33 @@ +namespace FantasyRealm.Infrastructure.Security +{ + /// + /// Configuration settings for JWT token generation and validation. + /// + public sealed class JwtSettings + { + /// + /// The configuration section name in appsettings.json. + /// + public const string SectionName = "Jwt"; + + /// + /// The secret key used to sign JWT tokens. Must be at least 32 characters. + /// + public string Secret { get; init; } = string.Empty; + + /// + /// The issuer claim for the JWT token. + /// + public string Issuer { get; init; } = string.Empty; + + /// + /// The audience claim for the JWT token. + /// + public string Audience { get; init; } = string.Empty; + + /// + /// The token expiration time in hours. + /// + public int ExpirationHours { get; init; } = 24; + } +} diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs new file mode 100644 index 0000000..d020a4f --- /dev/null +++ b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using FantasyRealm.Application.Interfaces; + +namespace FantasyRealm.Infrastructure.Security +{ + /// + /// Generates cryptographically secure passwords meeting CNIL requirements. + /// + public sealed class SecurePasswordGenerator : IPasswordGenerator + { + private const string UppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private const string LowercaseChars = "abcdefghijklmnopqrstuvwxyz"; + private const string DigitChars = "0123456789"; + private const string SpecialChars = "@#$%^&*!?-_+="; + private const string AllChars = UppercaseChars + LowercaseChars + DigitChars + SpecialChars; + private const int MinimumLength = 12; + + /// + public string GenerateSecurePassword(int length = 16) + { + if (length < MinimumLength) + { + length = MinimumLength; + } + + var password = new char[length]; + var remainingPositions = Enumerable.Range(0, length).ToList(); + + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(UppercaseChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(LowercaseChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(DigitChars); + password[TakeRandomPosition(remainingPositions)] = GetRandomChar(SpecialChars); + + foreach (var position in remainingPositions) + { + password[position] = GetRandomChar(AllChars); + } + + return new string(password); + } + + private static char GetRandomChar(string characterSet) + { + return characterSet[RandomNumberGenerator.GetInt32(characterSet.Length)]; + } + + private static int TakeRandomPosition(List positions) + { + var index = RandomNumberGenerator.GetInt32(positions.Count); + var position = positions[index]; + positions.RemoveAt(index); + return position; + } + } +} diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs new file mode 100644 index 0000000..cd75ccb --- /dev/null +++ b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs @@ -0,0 +1,790 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Json; +using FantasyRealm.Application.DTOs; +using FantasyRealm.Domain.Entities; +using FantasyRealm.Infrastructure.Persistence; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace FantasyRealm.Tests.Integration.Controllers +{ + /// + /// Integration tests for AuthController endpoints using Testcontainers. + /// + [Trait("Category", "Integration")] + [Trait("Category", "Auth")] + public class AuthControllerIntegrationTests : IClassFixture + { + private readonly HttpClient _client; + private readonly FantasyRealmWebApplicationFactory _factory; + + public AuthControllerIntegrationTests(FantasyRealmWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Register_WithValidData_ReturnsCreatedAndUserResponse() + { + // Arrange + var request = new + { + Email = $"test_{Guid.NewGuid():N}@example.com", + Pseudo = $"User{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Email.Should().Be(request.Email.ToLowerInvariant()); + result.Pseudo.Should().Be(request.Pseudo); + result.Role.Should().Be("User"); + result.Id.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Register_WithValidData_SendsWelcomeEmail() + { + // Arrange + _factory.EmailServiceMock.Reset(); + var request = new + { + Email = $"email_{Guid.NewGuid():N}@example.com", + Pseudo = $"User{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + + // Act + await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert - Wait for fire-and-forget email task + await Task.Delay(500); + _factory.EmailServiceMock.Verify( + e => e.SendWelcomeEmailAsync( + request.Email.ToLowerInvariant(), + request.Pseudo, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Register_WithExistingEmail_ReturnsConflict() + { + // Arrange + var email = $"duplicate_{Guid.NewGuid():N}@example.com"; + var firstRequest = new + { + Email = email, + Pseudo = $"First{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + await _client.PostAsJsonAsync("/api/auth/register", firstRequest); + + var secondRequest = new + { + Email = email, + Pseudo = $"Second{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", secondRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("email"); + } + + [Fact] + public async Task Register_WithExistingPseudo_ReturnsConflict() + { + // Arrange + var pseudo = $"Dup{Guid.NewGuid():N}"[..20]; + var firstRequest = new + { + Email = $"first_{Guid.NewGuid():N}@example.com", + Pseudo = pseudo, + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + await _client.PostAsJsonAsync("/api/auth/register", firstRequest); + + var secondRequest = new + { + Email = $"second_{Guid.NewGuid():N}@example.com", + Pseudo = pseudo, + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", secondRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Conflict); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("pseudo"); + } + + [Fact] + public async Task Register_WithMismatchedPasswords_ReturnsBadRequest() + { + // Arrange + var request = new + { + Email = $"mismatch_{Guid.NewGuid():N}@example.com", + Pseudo = $"User{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "DifferentPass@123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("correspondent"); + } + + [Fact] + public async Task Register_WithWeakPassword_ReturnsBadRequest() + { + // Arrange + var request = new + { + Email = $"weak_{Guid.NewGuid():N}@example.com", + Pseudo = $"User{Guid.NewGuid():N}"[..20], + Password = "weak", + ConfirmPassword = "weak" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Theory] + [InlineData("", "TestUser", "MySecure@Pass123", "MySecure@Pass123")] + [InlineData("invalid-email", "TestUser", "MySecure@Pass123", "MySecure@Pass123")] + [InlineData("test@example.com", "", "MySecure@Pass123", "MySecure@Pass123")] + [InlineData("test@example.com", "ab", "MySecure@Pass123", "MySecure@Pass123")] + public async Task Register_WithInvalidData_ReturnsBadRequest( + string email, string pseudo, string password, string confirmPassword) + { + // Arrange + var request = new { Email = email, Pseudo = pseudo, Password = password, ConfirmPassword = confirmPassword }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Register_NormalizesEmailToLowercase() + { + // Arrange + var email = $"UPPER_{Guid.NewGuid():N}@EXAMPLE.COM"; + var request = new + { + Email = email, + Pseudo = $"User{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + var result = await response.Content.ReadFromJsonAsync(); + result!.Email.Should().Be(email.ToLowerInvariant()); + } + + #region Login Tests + + [Fact] + public async Task Login_WithValidCredentials_ReturnsOkAndToken() + { + // Arrange - Register a user first + var email = $"login_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"Login{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginRequest = new { Email = email, Password = password }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Token.Should().NotBeNullOrEmpty(); + result.ExpiresAt.Should().BeAfter(DateTime.UtcNow); + result.User.Email.Should().Be(email.ToLowerInvariant()); + result.User.Pseudo.Should().Be(pseudo); + result.User.Role.Should().Be("User"); + result.MustChangePassword.Should().BeFalse(); + } + + [Fact] + public async Task Login_WithValidCredentials_ReturnsValidJwtToken() + { + // Arrange + var email = $"jwt_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"Jwt{Guid.NewGuid():N}"[..20]; + + var registerResponse = await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + var registeredUser = await registerResponse.Content.ReadFromJsonAsync(); + + var loginRequest = new { Email = email, Password = password }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + var result = await response.Content.ReadFromJsonAsync(); + + // Assert - Decode and validate JWT claims + var handler = new JwtSecurityTokenHandler(); + var token = handler.ReadJwtToken(result!.Token); + + token.Claims.Should().Contain(c => c.Type == "sub" && c.Value == registeredUser!.Id.ToString()); + token.Claims.Should().Contain(c => c.Type == "email" && c.Value == email.ToLowerInvariant()); + token.Claims.Should().Contain(c => c.Type == "pseudo" && c.Value == pseudo); + token.Claims.Should().Contain(c => c.Type.Contains("role") && c.Value == "User"); + } + + [Fact] + public async Task Login_WithNonExistentEmail_ReturnsUnauthorized() + { + // Arrange + var loginRequest = new + { + Email = $"nonexistent_{Guid.NewGuid():N}@example.com", + Password = "SomePassword@123" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Be("Identifiants incorrects."); + } + + [Fact] + public async Task Login_WithWrongPassword_ReturnsUnauthorized() + { + // Arrange - Register a user first + var email = $"wrongpwd_{Guid.NewGuid():N}@example.com"; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = $"Wrong{Guid.NewGuid():N}"[..20], + Password = "MySecure@Pass123", + ConfirmPassword = "MySecure@Pass123" + }); + + var loginRequest = new { Email = email, Password = "WrongPassword@123" }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Be("Identifiants incorrects."); + } + + [Fact] + public async Task Login_WithSuspendedAccount_ReturnsForbidden() + { + // Arrange - Register a user first, then suspend them + var email = $"tosuspend_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = $"ToSusp{Guid.NewGuid():N}"[..20], + Password = password, + ConfirmPassword = password + }); + + // Suspend the user directly in DB + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + user.IsSuspended = true; + await context.SaveChangesAsync(); + + var loginRequest = new { Email = email, Password = password }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Be("Votre compte a été suspendu."); + } + + [Fact] + public async Task Login_WithUppercaseEmail_NormalizesAndSucceeds() + { + // Arrange + var email = $"normalize_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = $"Norm{Guid.NewGuid():N}"[..20], + Password = password, + ConfirmPassword = password + }); + + var loginRequest = new { Email = email.ToUpperInvariant(), Password = password }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Theory] + [InlineData("", "password")] + [InlineData("test@example.com", "")] + public async Task Login_WithEmptyFields_ReturnsBadRequest(string email, string password) + { + // Arrange + var loginRequest = new { Email = email, Password = password }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + #endregion + + #region ChangePassword Tests + + [Fact] + public async Task ChangePassword_WithValidData_ReturnsOkAndNewToken() + { + // Arrange - Register and login + var email = $"changepwd_{Guid.NewGuid():N}@example.com"; + var oldPassword = "MySecure@Pass123"; + var newPassword = "NewSecure@Pass456"; + var pseudo = $"ChgPwd{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = oldPassword, + ConfirmPassword = oldPassword + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = oldPassword, + NewPassword = newPassword, + ConfirmNewPassword = newPassword + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var result = await response.Content.ReadFromJsonAsync(); + result.Should().NotBeNull(); + result!.Token.Should().NotBeNullOrEmpty(); + result.Token.Should().NotBe(loginResult.Token); + result.ExpiresAt.Should().BeAfter(DateTime.UtcNow); + result.User.Email.Should().Be(email.ToLowerInvariant()); + } + + [Fact] + public async Task ChangePassword_WithNewToken_CanLoginWithNewPassword() + { + // Arrange - Register, login, and change password + var email = $"newlogin_{Guid.NewGuid():N}@example.com"; + var oldPassword = "MySecure@Pass123"; + var newPassword = "NewSecure@Pass456"; + var pseudo = $"NewLog{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = oldPassword, + ConfirmPassword = oldPassword + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + using var changeRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + changeRequest.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + changeRequest.Content = JsonContent.Create(new + { + CurrentPassword = oldPassword, + NewPassword = newPassword, + ConfirmNewPassword = newPassword + }); + + await _client.SendAsync(changeRequest); + + // Act - Try to login with new password + var newLoginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = newPassword }); + + // Assert + newLoginResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task ChangePassword_WithoutAuthentication_ReturnsUnauthorized() + { + // Arrange + var request = new + { + CurrentPassword = "OldPassword@123", + NewPassword = "NewPassword@456", + ConfirmNewPassword = "NewPassword@456" + }; + + // Act + var response = await _client.PostAsJsonAsync("/api/auth/change-password", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ChangePassword_WithInvalidToken_ReturnsUnauthorized() + { + // Arrange + var request = new + { + CurrentPassword = "OldPassword@123", + NewPassword = "NewPassword@456", + ConfirmNewPassword = "NewPassword@456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", "Bearer invalid.token.here"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task ChangePassword_WithWrongCurrentPassword_ReturnsUnauthorized() + { + // Arrange + var email = $"wrongcur_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"WrngCur{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = "WrongPassword@123", + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("mot de passe actuel"); + } + + [Fact] + public async Task ChangePassword_WithMismatchedNewPasswords_ReturnsBadRequest() + { + // Arrange + var email = $"mismatch_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"MisMat{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "DifferentPass@789" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("correspondent"); + } + + [Fact] + public async Task ChangePassword_WithWeakNewPassword_ReturnsBadRequest() + { + // Arrange + var email = $"weaknew_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"WeakNw{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "weak", + ConfirmNewPassword = "weak" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ChangePassword_WithSameAsOldPassword_ReturnsBadRequest() + { + // Arrange + var email = $"sameold_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"SameOl{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = password, + ConfirmNewPassword = password + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("différent"); + } + + [Fact] + public async Task ChangePassword_WithSuspendedAccount_ReturnsForbidden() + { + // Arrange + var email = $"suspchg_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"SusChg{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + + // Suspend the user directly in DB + using var scope = _factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + user.IsSuspended = true; + await context.SaveChangesAsync(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + var response = await _client.SendAsync(requestMessage); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + var error = await response.Content.ReadFromJsonAsync(); + error?.Message.Should().Contain("suspendu"); + } + + [Fact] + public async Task ChangePassword_SetsMustChangePasswordToFalse() + { + // Arrange + var email = $"mustchg_{Guid.NewGuid():N}@example.com"; + var password = "MySecure@Pass123"; + var pseudo = $"MustCh{Guid.NewGuid():N}"[..20]; + + await _client.PostAsJsonAsync("/api/auth/register", new + { + Email = email, + Pseudo = pseudo, + Password = password, + ConfirmPassword = password + }); + + // Set MustChangePassword to true in DB + using (var scope = _factory.Services.CreateScope()) + { + var context = scope.ServiceProvider.GetRequiredService(); + var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + user.MustChangePassword = true; + await context.SaveChangesAsync(); + } + + var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password }); + var loginResult = await loginResponse.Content.ReadFromJsonAsync(); + loginResult!.MustChangePassword.Should().BeTrue(); + + var request = new + { + CurrentPassword = password, + NewPassword = "NewSecure@Pass456", + ConfirmNewPassword = "NewSecure@Pass456" + }; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password"); + requestMessage.Headers.Add("Authorization", $"Bearer {loginResult.Token}"); + requestMessage.Content = JsonContent.Create(request); + + // Act + await _client.SendAsync(requestMessage); + + // Assert - Check DB + using var verifyScope = _factory.Services.CreateScope(); + var verifyContext = verifyScope.ServiceProvider.GetRequiredService(); + var updatedUser = await verifyContext.Users.FirstAsync(u => u.Email == email.ToLowerInvariant()); + updatedUser.MustChangePassword.Should().BeFalse(); + } + + #endregion + + private record ErrorResponse(string Message); + } +} diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj index d1783bd..08b3b17 100644 --- a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj +++ b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj @@ -11,6 +11,7 @@ + diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs new file mode 100644 index 0000000..a420c80 --- /dev/null +++ b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs @@ -0,0 +1,113 @@ +using FantasyRealm.Api; +using FantasyRealm.Application.Interfaces; +using FantasyRealm.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Npgsql; +using Testcontainers.PostgreSql; + +namespace FantasyRealm.Tests.Integration +{ + /// + /// Custom WebApplicationFactory for integration testing with Testcontainers. + /// Executes real SQL scripts from database/sql/ for production parity. + /// + public class FantasyRealmWebApplicationFactory : WebApplicationFactory, IAsyncLifetime + { + private readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .WithDatabase("fantasyrealm_test") + .WithUsername("test") + .WithPassword("test") + .Build(); + + public Mock EmailServiceMock { get; } = new(); + + public async Task InitializeAsync() + { + await _postgresContainer.StartAsync(); + await ExecuteSqlScriptsAsync(); + } + + private async Task ExecuteSqlScriptsAsync() + { + var sqlDirectory = FindSqlDirectory(); + var sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql") + .OrderBy(f => f) + .ToList(); + + await using var connection = new NpgsqlConnection(_postgresContainer.GetConnectionString()); + await connection.OpenAsync(); + + foreach (var sqlFile in sqlFiles) + { + var sql = await File.ReadAllTextAsync(sqlFile); + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + } + + private static string FindSqlDirectory() + { + var currentDir = Directory.GetCurrentDirectory(); + var searchDir = currentDir; + + while (searchDir != null) + { + var sqlPath = Path.Combine(searchDir, "database", "sql"); + if (Directory.Exists(sqlPath)) + { + return sqlPath; + } + + var parentSqlPath = Path.Combine(searchDir, "..", "..", "..", "..", "..", "database", "sql"); + if (Directory.Exists(parentSqlPath)) + { + return Path.GetFullPath(parentSqlPath); + } + + searchDir = Directory.GetParent(searchDir)?.FullName; + } + + throw new DirectoryNotFoundException( + $"Could not find database/sql directory. Current directory: {currentDir}"); + } + + public new async Task DisposeAsync() + { + await _postgresContainer.DisposeAsync(); + await base.DisposeAsync(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureTestServices(services => + { + var dbDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + + if (dbDescriptor != null) + { + services.Remove(dbDescriptor); + } + + services.AddDbContext(options => + options.UseNpgsql(_postgresContainer.GetConnectionString())); + + var emailDescriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(IEmailService)); + + if (emailDescriptor != null) + { + services.Remove(emailDescriptor); + } + + services.AddScoped(_ => EmailServiceMock.Object); + }); + } + } +} diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs new file mode 100644 index 0000000..4ca2d19 --- /dev/null +++ b/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs @@ -0,0 +1,354 @@ +using FantasyRealm.Domain.Entities; +using FantasyRealm.Domain.Enums; +using FantasyRealm.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FantasyRealm.Tests.Integration.Persistence +{ + /// + /// Integration tests for FantasyRealmDbContext using real PostgreSQL via Testcontainers. + /// Validates that the EF Core model matches the SQL schema. + /// + [Trait("Category", "Integration")] + [Trait("Category", "Persistence")] + public class FantasyRealmDbContextIntegrationTests : IClassFixture + { + private readonly FantasyRealmWebApplicationFactory _factory; + + public FantasyRealmDbContextIntegrationTests(FantasyRealmWebApplicationFactory factory) + { + _factory = factory; + } + + private FantasyRealmDbContext CreateDbContext() + { + var scope = _factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task CanRetrieveSeededRoles() + { + // Arrange + using var context = CreateDbContext(); + + // Act + var roles = await context.Roles.OrderBy(r => r.Id).ToListAsync(); + + // Assert + Assert.Equal(3, roles.Count); + Assert.Equal("User", roles[0].Label); + Assert.Equal("Employee", roles[1].Label); + Assert.Equal("Admin", roles[2].Label); + } + + [Fact] + public async Task CanAddUserWithRole() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + + var user = new User + { + Pseudo = $"player_{Guid.NewGuid():N}"[..20], + Email = $"player_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + + // Act + context.Users.Add(user); + await context.SaveChangesAsync(); + + // Assert + var savedUser = await context.Users + .Include(u => u.Role) + .FirstAsync(u => u.Id == user.Id); + + Assert.Equal(user.Pseudo, savedUser.Pseudo); + Assert.Equal("User", savedUser.Role.Label); + } + + [Fact] + public async Task CanAddCharacterWithUser() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + + var user = new User + { + Pseudo = $"char_owner_{Guid.NewGuid():N}"[..20], + Email = $"char_owner_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var character = new Character + { + Name = "Thorin", + Gender = Gender.Male, + SkinColor = "#E8BEAC", + EyeColor = "#4A90D9", + HairColor = "#2C1810", + EyeShape = "almond", + NoseShape = "aquiline", + MouthShape = "thin", + UserId = user.Id + }; + + // Act + context.Characters.Add(character); + await context.SaveChangesAsync(); + + // Assert + var savedCharacter = await context.Characters + .Include(c => c.User) + .FirstAsync(c => c.Id == character.Id); + + Assert.Equal("Thorin", savedCharacter.Name); + Assert.Equal(Gender.Male, savedCharacter.Gender); + Assert.Equal(user.Pseudo, savedCharacter.User.Pseudo); + } + + [Fact] + public async Task CanEquipArticleToCharacter() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + + var user = new User + { + Pseudo = $"equip_user_{Guid.NewGuid():N}"[..20], + Email = $"equip_user_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user); + + var article = new Article + { + Name = "Iron Sword", + Type = ArticleType.Weapon + }; + context.Articles.Add(article); + await context.SaveChangesAsync(); + + var character = new Character + { + Name = "Warrior", + Gender = Gender.Male, + SkinColor = "#E8BEAC", + EyeColor = "#4A90D9", + HairColor = "#2C1810", + EyeShape = "almond", + NoseShape = "aquiline", + MouthShape = "thin", + UserId = user.Id + }; + context.Characters.Add(character); + await context.SaveChangesAsync(); + + var characterArticle = new CharacterArticle + { + CharacterId = character.Id, + ArticleId = article.Id + }; + + // Act + context.CharacterArticles.Add(characterArticle); + await context.SaveChangesAsync(); + + // Assert + var savedCharacter = await context.Characters + .Include(c => c.CharacterArticles) + .ThenInclude(ca => ca.Article) + .FirstAsync(c => c.Id == character.Id); + + Assert.Single(savedCharacter.CharacterArticles); + Assert.Equal("Iron Sword", savedCharacter.CharacterArticles.First().Article.Name); + } + + [Fact] + public async Task CanAddCommentToCharacter() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + + var owner = new User + { + Pseudo = $"owner_{Guid.NewGuid():N}"[..20], + Email = $"owner_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + var commenter = new User + { + Pseudo = $"commenter_{Guid.NewGuid():N}"[..20], + Email = $"commenter_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.AddRange(owner, commenter); + await context.SaveChangesAsync(); + + var character = new Character + { + Name = "SharedHero", + Gender = Gender.Female, + SkinColor = "#E8BEAC", + EyeColor = "#4A90D9", + HairColor = "#2C1810", + EyeShape = "almond", + NoseShape = "aquiline", + MouthShape = "thin", + IsShared = true, + IsAuthorized = true, + UserId = owner.Id + }; + context.Characters.Add(character); + await context.SaveChangesAsync(); + + var comment = new Comment + { + Rating = 5, + Text = "Amazing character!", + Status = CommentStatus.Approved, + CommentedAt = DateTime.UtcNow, + CharacterId = character.Id, + AuthorId = commenter.Id + }; + + // Act + context.Comments.Add(comment); + await context.SaveChangesAsync(); + + // Assert + var savedComment = await context.Comments + .Include(c => c.Character) + .Include(c => c.Author) + .FirstAsync(c => c.Id == comment.Id); + + Assert.Equal(5, savedComment.Rating); + Assert.Equal("SharedHero", savedComment.Character.Name); + Assert.Equal(commenter.Pseudo, savedComment.Author.Pseudo); + } + + [Fact] + public async Task UniqueEmailConstraint_ThrowsOnDuplicate() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + var email = $"unique_{Guid.NewGuid():N}@test.com"; + + var user1 = new User + { + Pseudo = $"user1_{Guid.NewGuid():N}"[..20], + Email = email, + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user1); + await context.SaveChangesAsync(); + + var user2 = new User + { + Pseudo = $"user2_{Guid.NewGuid():N}"[..20], + Email = email, + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user2); + + // Act & Assert + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } + + [Fact] + public async Task UniquePseudoConstraint_ThrowsOnDuplicate() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + var pseudo = $"dup_{Guid.NewGuid():N}"[..20]; + + var user1 = new User + { + Pseudo = pseudo, + Email = $"user1_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user1); + await context.SaveChangesAsync(); + + var user2 = new User + { + Pseudo = pseudo, + Email = $"user2_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user2); + + // Act & Assert + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } + + [Fact] + public async Task CommentRatingConstraint_EnforcesRange() + { + // Arrange + using var context = CreateDbContext(); + var role = await context.Roles.FirstAsync(r => r.Label == "User"); + + var user = new User + { + Pseudo = $"rating_user_{Guid.NewGuid():N}"[..20], + Email = $"rating_user_{Guid.NewGuid():N}@test.com", + PasswordHash = "hashed_password", + RoleId = role.Id + }; + context.Users.Add(user); + await context.SaveChangesAsync(); + + var character = new Character + { + Name = "TestChar", + Gender = Gender.Male, + SkinColor = "#E8BEAC", + EyeColor = "#4A90D9", + HairColor = "#2C1810", + EyeShape = "almond", + NoseShape = "aquiline", + MouthShape = "thin", + IsShared = true, + IsAuthorized = true, + UserId = user.Id + }; + context.Characters.Add(character); + await context.SaveChangesAsync(); + + var invalidComment = new Comment + { + Rating = 10, + Text = "Invalid rating", + Status = CommentStatus.Pending, + CommentedAt = DateTime.UtcNow, + CharacterId = character.Id, + AuthorId = user.Id + }; + context.Comments.Add(invalidComment); + + // Act & Assert + await Assert.ThrowsAsync(() => context.SaveChangesAsync()); + } + } +} diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs index e8418bf..7ddb3a1 100644 --- a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs +++ b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs @@ -4,16 +4,21 @@ namespace FantasyRealm.Tests.Unit.Email { public class EmailTemplatesTests { + private const string TestBaseUrl = "https://test.fantasy-realm.com"; + [Fact] public void GetWelcomeTemplate_ContainsPseudo() { + // Arrange var pseudo = "TestPlayer"; - var result = EmailTemplates.GetWelcomeTemplate(pseudo); + // Act + var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl); + // Assert Assert.Contains(pseudo, result); - Assert.Contains("Welcome to FantasyRealm", result); - Assert.Contains("Create Your First Character", result); + Assert.Contains("Bienvenue", result); + Assert.Contains("Créer mon premier personnage", result); } [Fact] @@ -21,24 +26,35 @@ public void GetWelcomeTemplate_EscapesHtmlCharacters() { var pseudo = ""; - var result = EmailTemplates.GetWelcomeTemplate(pseudo); + var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl); Assert.DoesNotContain(""; + var temporaryPassword = "TempPass@123!"; + + var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, TestBaseUrl); + + Assert.DoesNotContain("