Added frontend. Working on user management

This commit is contained in:
2025-09-16 22:58:28 +02:00
parent 10650420ef
commit 2958d5cf82
31 changed files with 5101 additions and 15 deletions

125
Readme.md
View File

@@ -30,6 +30,7 @@ MyDocManager is a real-time document processing application that automatically d
4. **mongodb**: Final database for processing results 4. **mongodb**: Final database for processing results
5. **frontend**: React interface for monitoring and file access 5. **frontend**: React interface for monitoring and file access
## Data Flow ## Data Flow
1. **File Detection**: Watchdog monitors target directory in real-time 1. **File Detection**: Watchdog monitors target directory in real-time
@@ -73,8 +74,9 @@ The application is designed for container-based development with hot-reload capa
4. **mongodb**: Final database for processing results 4. **mongodb**: Final database for processing results
5. **frontend**: React interface for monitoring and file access 5. **frontend**: React interface for monitoring and file access
## Project Structure (To be implemented) ## Project Structure
```
MyDocManager/ MyDocManager/
├── docker-compose.yml ├── docker-compose.yml
├── src/ ├── src/
@@ -85,7 +87,35 @@ MyDocManager/
│ │ │ ├── main.py │ │ │ ├── main.py
│ │ │ ├── file_watcher.py │ │ │ ├── file_watcher.py
│ │ │ ├── celery_app.py │ │ │ ├── celery_app.py
│ │ │ ── api/ │ │ │ ── config/
│ │ │ │ ├── __init__.py
│ │ │ │ └── settings.py # JWT, MongoDB config
│ │ │ ├── models/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── user.py # User Pydantic models
│ │ │ │ └── auth.py # Auth Pydantic models
│ │ │ ├── database/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── connection.py # MongoDB connection
│ │ │ │ └── repositories/
│ │ │ │ ├── __init__.py
│ │ │ │ └── user_repository.py # User CRUD operations
│ │ │ ├── services/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── auth_service.py # JWT & password logic
│ │ │ │ ├── user_service.py # User business logic
│ │ │ │ └── init_service.py # Admin creation at startup
│ │ │ ├── api/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── dependencies.py # Auth dependencies
│ │ │ │ └── routes/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── auth.py # Authentication routes
│ │ │ │ └── users.py # User management routes
│ │ │ └── utils/
│ │ │ ├── __init__.py
│ │ │ ├── security.py # Password utilities
│ │ │ └── exceptions.py # Custom exceptions
│ ├── worker/ │ ├── worker/
│ │ ├── Dockerfile │ │ ├── Dockerfile
│ │ ├── requirements.txt │ │ ├── requirements.txt
@@ -96,10 +126,43 @@ MyDocManager/
│ └── src/ │ └── src/
├── tests/ ├── tests/
│ ├── file-processor/ │ ├── file-processor/
│ │ ├── test_auth/
│ │ ├── test_users/
│ │ └── test_services/
│ └── worker/ │ └── worker/
├── volumes/ ├── volumes/
│ └── watched_files/ │ └── watched_files/
└── README.md └── README.md
```
## Authentication & User Management
### Security Features
- **JWT Authentication**: Stateless authentication with 24-hour token expiration
- **Password Security**: bcrypt hashing with automatic salting
- **Role-Based Access**: Admin and User roles with granular permissions
- **Protected Routes**: All user management APIs require valid authentication
- **Auto Admin Creation**: Default admin user created on first startup
### User Roles
- **Admin**: Full access to user management (create, read, update, delete users)
- **User**: Limited access (view own profile, access document processing features)
### Authentication Flow
1. **Login**: User provides credentials → Server validates → Returns JWT token
2. **API Access**: Client includes JWT in Authorization header
3. **Token Validation**: Server verifies token signature and expiration
4. **Role Check**: Server validates user permissions for requested resource
### User Management APIs
```
POST /auth/login # Generate JWT token
GET /users # List all users (admin only)
POST /users # Create new user (admin only)
PUT /users/{user_id} # Update user (admin only)
DELETE /users/{user_id} # Delete user (admin only)
GET /users/me # Get current user profile (authenticated users)
```
## Docker Commands Reference ## Docker Commands Reference
@@ -211,7 +274,14 @@ curl -X POST http://localhost:8000/test-task \
# Monitor Celery tasks # Monitor Celery tasks
docker-compose logs -f worker docker-compose logs -f worker
``` ```
## Default Admin User
On first startup, the application automatically creates a default admin user:
- **Username**: `admin`
- **Password**: `admin`
- **Role**: `admin`
- **Email**: `admin@mydocmanager.local`
**⚠️ Important**: Change the default admin password immediately after first login in production environments.
## Key Implementation Notes ## Key Implementation Notes
@@ -221,6 +291,12 @@ docker-compose logs -f worker
- **Naming**: snake_case for variables and functions - **Naming**: snake_case for variables and functions
- **Testing**: pytest with test_i_can_xxx / test_i_cannot_xxx patterns - **Testing**: pytest with test_i_can_xxx / test_i_cannot_xxx patterns
### Security Best Practices
- **Password Storage**: Never store plain text passwords, always use bcrypt hashing
- **JWT Secrets**: Use strong, randomly generated secret keys in production
- **Token Expiration**: 24-hour expiration with secure signature validation
- **Role Validation**: Server-side role checking for all protected endpoints
### Dependencies Management ### Dependencies Management
- **Package Manager**: pip (standard) - **Package Manager**: pip (standard)
- **External Dependencies**: Listed in each service's requirements.txt - **External Dependencies**: Listed in each service's requirements.txt
@@ -228,15 +304,20 @@ docker-compose logs -f worker
### Testing Strategy ### Testing Strategy
- All code must be testable - All code must be testable
- Unit tests for each processing function - Unit tests for each authentication and user management function
- Integration tests for file processing workflow - Integration tests for complete authentication flow
- Tests validated before implementation - Tests validated before implementation
### Critical Architecture Decisions Made ### Critical Architecture Decisions Made
1. **Option Selected**: Single FastAPI service handles both API and file watching 1. **JWT Authentication**: Simple token-based auth with 24-hour expiration
2. **Celery with Redis**: Chosen over other async patterns for scalability 2. **Role-Based Access**: Admin/User roles for granular permissions
3. **EasyOCR Preferred**: Selected over Tesseract for modern OCR needs 3. **bcrypt Password Hashing**: Industry-standard password security
4. **Container Development**: Hot-reload setup required for development workflow 4. **MongoDB User Storage**: Centralized user management in main database
5. **Auto Admin Creation**: Automatic setup for first-time deployment
6. **Single FastAPI Service**: Handles both API and file watching with authentication
7. **Celery with Redis**: Chosen over other async patterns for scalability
8. **EasyOCR Preferred**: Selected over Tesseract for modern OCR needs
9. **Container Development**: Hot-reload setup required for development workflow
### Development Process Requirements ### Development Process Requirements
1. **Collaborative Validation**: All options must be explained before coding 1. **Collaborative Validation**: All options must be explained before coding
@@ -245,11 +326,25 @@ docker-compose logs -f worker
4. **Error Handling**: Clear problem explanation required before proposing fixes 4. **Error Handling**: Clear problem explanation required before proposing fixes
### Next Implementation Steps ### Next Implementation Steps
1. Create docker-compose.yml with all services 1. Create docker-compose.yml with all services
2. Implement basic FastAPI service structure 2. ✅ Define user management and authentication architecture
3. Add watchdog file monitoring 3. Implement user models and authentication services
4. Create Celery task structure 4. Create protected API routes for user management
5. Implement document processing tasks 5. Add automatic admin user creation
6. Build React monitoring interface 6. Implement basic FastAPI service structure
7. Add watchdog file monitoring
8. Create Celery task structure
9. Implement document processing tasks
10. Build React monitoring interface with authentication
""" ### prochaines étapes
MongoDB CRUD
Nous devons absolument mocker MongoDB pour les tests unitaires avec pytest-mock
Fichiers à créer:
* app/models/auht.py => déjà fait
* app/models/user.py => déjà fait
* app/database/connection.py
* Utilise les settings pour l'URL MongoDB. Il faut créer un fichier de configuration (app/config/settings.py)
* Fonction get_database() + gestion des erreurs
* Configuration via variables d'environnement
* app/database/repositories/user_repository.py

View File

@@ -1,21 +1,30 @@
amqp==5.3.1 amqp==5.3.1
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.10.0 anyio==4.10.0
bcrypt==4.3.0
billiard==4.2.1 billiard==4.2.1
bson==0.5.10
celery==5.5.3 celery==5.5.3
click==8.2.1 click==8.2.1
click-didyoumean==0.3.1 click-didyoumean==0.3.1
click-plugins==1.1.1.2 click-plugins==1.1.1.2
click-repl==0.3.0 click-repl==0.3.0
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.116.1 fastapi==0.116.1
h11==0.16.0 h11==0.16.0
httptools==0.6.4 httptools==0.6.4
idna==3.10 idna==3.10
iniconfig==2.1.0
kombu==5.5.4 kombu==5.5.4
packaging==25.0 packaging==25.0
pluggy==1.6.0
prompt_toolkit==3.0.52 prompt_toolkit==3.0.52
pydantic==2.11.9 pydantic==2.11.9
pydantic_core==2.33.2 pydantic_core==2.33.2
Pygments==2.19.2
pymongo==4.15.0
pytest==8.4.2
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
python-dotenv==1.1.1 python-dotenv==1.1.1
PyYAML==6.0.2 PyYAML==6.0.2

View File

@@ -0,0 +1,85 @@
"""
Application configuration settings.
This module handles environment variables and application configuration
using simple os.getenv() approach without external validation libraries.
"""
import os
from typing import Optional
def get_mongodb_url() -> str:
"""
Get MongoDB connection URL from environment variables.
Returns:
str: MongoDB connection URL
Returns default localhost URL if MONGODB_URL not set.
"""
return os.getenv("MONGODB_URL", "mongodb://localhost:27017")
def get_mongodb_database_name() -> str:
"""
Get MongoDB database name from environment variables.
Returns:
str: MongoDB database name
"""
return os.getenv("MONGODB_DATABASE", "mydocmanager")
def get_jwt_secret_key() -> str:
"""
Get JWT secret key from environment variables.
Returns:
str: JWT secret key
Raises:
ValueError: If JWT_SECRET is not set in production
"""
secret = os.getenv("JWT_SECRET")
if not secret:
# For development, provide a default key with warning
if os.getenv("ENVIRONMENT", "development") == "development":
print("WARNING: Using default JWT secret key for development")
return "dev-secret-key-change-in-production"
else:
raise ValueError("JWT_SECRET environment variable must be set in production")
return secret
def get_jwt_algorithm() -> str:
"""
Get JWT algorithm from environment variables.
Returns:
str: JWT algorithm (default: HS256)
"""
return os.getenv("JWT_ALGORITHM", "HS256")
def get_jwt_expire_hours() -> int:
"""
Get JWT token expiration time in hours from environment variables.
Returns:
int: JWT expiration time in hours (default: 24)
"""
try:
return int(os.getenv("JWT_EXPIRE_HOURS", "24"))
except ValueError:
return 24
def is_development_environment() -> bool:
"""
Check if running in development environment.
Returns:
bool: True if development environment
"""
return os.getenv("ENVIRONMENT", "development").lower() == "development"

View File

@@ -0,0 +1,125 @@
"""
MongoDB database connection management.
This module handles MongoDB connection with fail-fast approach.
The application will terminate if MongoDB is not accessible at startup.
"""
import sys
from typing import Optional
from pymongo import MongoClient
from pymongo.database import Database
from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError
from ..config.settings import get_mongodb_url, get_mongodb_database_name
# Global variables for singleton pattern
_client: Optional[MongoClient] = None
_database: Optional[Database] = None
def create_mongodb_client() -> MongoClient:
"""
Create MongoDB client with connection validation.
Returns:
MongoClient: Connected MongoDB client
Raises:
SystemExit: If connection fails (fail-fast approach)
"""
mongodb_url = get_mongodb_url()
try:
# Create client with short timeout for fail-fast behavior
client = MongoClient(
mongodb_url,
serverSelectionTimeoutMS=5000, # 5 seconds timeout
connectTimeoutMS=5000,
socketTimeoutMS=5000
)
# Test connection by running admin command
client.admin.command('ping')
print(f"Successfully connected to MongoDB at {mongodb_url}")
return client
except (ConnectionFailure, ServerSelectionTimeoutError) as e:
print(f"ERROR: Failed to connect to MongoDB at {mongodb_url}")
print(f"Connection error: {str(e)}")
print("MongoDB is required for this application. Please ensure MongoDB is running and accessible.")
sys.exit(1)
except Exception as e:
print(f"ERROR: Unexpected error connecting to MongoDB: {str(e)}")
sys.exit(1)
def get_database() -> Database:
"""
Get MongoDB database instance (singleton pattern).
Returns:
Database: MongoDB database instance
This function implements singleton pattern to ensure only one
database connection is created throughout the application lifecycle.
"""
global _client, _database
if _database is None:
if _client is None:
_client = create_mongodb_client()
database_name = get_mongodb_database_name()
_database = _client[database_name]
print(f"Connected to database: {database_name}")
return _database
def close_database_connection():
"""
Close MongoDB database connection.
This function should be called during application shutdown
to properly close the database connection.
"""
global _client, _database
if _client is not None:
_client.close()
_client = None
_database = None
print("MongoDB connection closed")
def get_mongodb_client() -> Optional[MongoClient]:
"""
Get the raw MongoDB client instance.
Returns:
MongoClient or None: MongoDB client instance if connected
This is primarily for testing purposes or advanced use cases
where direct client access is needed.
"""
return _client
def test_database_connection() -> bool:
"""
Test if database connection is working.
Returns:
bool: True if connection is working, False otherwise
This function can be used for health checks.
"""
try:
db = get_database()
# Simple operation to test connection
db.command('ping')
return True
except Exception:
return False

View File

@@ -0,0 +1,221 @@
"""
User repository for MongoDB operations.
This module implements the repository pattern for user CRUD operations
with dependency injection of the database connection.
"""
from typing import Optional, List, Dict, Any
from datetime import datetime
from bson import ObjectId
from pymongo.database import Database
from pymongo.errors import DuplicateKeyError
from pymongo.collection import Collection
from app.models.user import UserCreate, UserInDB, UserUpdate
class UserRepository:
"""
Repository class for user CRUD operations in MongoDB.
This class handles all database operations related to users,
following the repository pattern with dependency injection.
"""
def __init__(self, database: Database):
"""
Initialize repository with database dependency.
Args:
database (Database): MongoDB database instance
"""
self.db = database
self.collection: Collection = database.users
# Create unique index on username for duplicate prevention
self._ensure_indexes()
def _ensure_indexes(self):
"""
Ensure required database indexes exist.
Creates unique index on username field to prevent duplicates.
"""
try:
self.collection.create_index("username", unique=True)
except Exception:
# Index might already exist, ignore error
pass
def create_user(self, user_data: UserCreate) -> UserInDB:
"""
Create a new user in the database.
Args:
user_data (UserCreate): User creation data
Returns:
UserInDB: Created user with database ID
Raises:
DuplicateKeyError: If username already exists
"""
user_dict = {
"username": user_data.username,
"email": user_data.email,
"hashed_password": user_data.hashed_password,
"role": user_data.role,
"is_active": user_data.is_active,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
try:
result = self.collection.insert_one(user_dict)
user_dict["_id"] = result.inserted_id
return UserInDB(**user_dict)
except DuplicateKeyError:
raise DuplicateKeyError(f"User with username '{user_data.username}' already exists")
def find_user_by_username(self, username: str) -> Optional[UserInDB]:
"""
Find user by username.
Args:
username (str): Username to search for
Returns:
UserInDB or None: User if found, None otherwise
"""
user_doc = self.collection.find_one({"username": username})
if user_doc:
return UserInDB(**user_doc)
return None
def find_user_by_id(self, user_id: str) -> Optional[UserInDB]:
"""
Find user by ID.
Args:
user_id (str): User ID to search for
Returns:
UserInDB or None: User if found, None otherwise
"""
try:
object_id = ObjectId(user_id)
user_doc = self.collection.find_one({"_id": object_id})
if user_doc:
return UserInDB(**user_doc)
except Exception:
# Invalid ObjectId format
pass
return None
def find_user_by_email(self, email: str) -> Optional[UserInDB]:
"""
Find user by email address.
Args:
email (str): Email address to search for
Returns:
UserInDB or None: User if found, None otherwise
"""
user_doc = self.collection.find_one({"email": email})
if user_doc:
return UserInDB(**user_doc)
return None
def update_user(self, user_id: str, user_update: UserUpdate) -> Optional[UserInDB]:
"""
Update user information.
Args:
user_id (str): User ID to update
user_update (UserUpdate): Updated user data
Returns:
UserInDB or None: Updated user if found, None otherwise
"""
try:
object_id = ObjectId(user_id)
# Build update document with only provided fields
update_data = {"updated_at": datetime.utcnow()}
if user_update.email is not None:
update_data["email"] = user_update.email
if user_update.hashed_password is not None:
update_data["hashed_password"] = user_update.hashed_password
if user_update.role is not None:
update_data["role"] = user_update.role
if user_update.is_active is not None:
update_data["is_active"] = user_update.is_active
result = self.collection.update_one(
{"_id": object_id},
{"$set": update_data}
)
if result.matched_count > 0:
return self.find_user_by_id(user_id)
except Exception:
# Invalid ObjectId format or other errors
pass
return None
def delete_user(self, user_id: str) -> bool:
"""
Delete user from database.
Args:
user_id (str): User ID to delete
Returns:
bool: True if user was deleted, False otherwise
"""
try:
object_id = ObjectId(user_id)
result = self.collection.delete_one({"_id": object_id})
return result.deleted_count > 0
except Exception:
# Invalid ObjectId format
return False
def list_users(self, skip: int = 0, limit: int = 100) -> List[UserInDB]:
"""
List users with pagination.
Args:
skip (int): Number of users to skip (default: 0)
limit (int): Maximum number of users to return (default: 100)
Returns:
List[UserInDB]: List of users
"""
cursor = self.collection.find().skip(skip).limit(limit)
return [UserInDB(**user_doc) for user_doc in cursor]
def count_users(self) -> int:
"""
Count total number of users.
Returns:
int: Total number of users in database
"""
return self.collection.count_documents({})
def user_exists(self, username: str) -> bool:
"""
Check if user exists by username.
Args:
username (str): Username to check
Returns:
bool: True if user exists, False otherwise
"""
return self.collection.count_documents({"username": username}) > 0

View File

@@ -0,0 +1,14 @@
"""
Authentication models and enums for user management.
Contains user roles enumeration and authentication-related Pydantic models.
"""
from enum import Enum
class UserRole(str, Enum):
"""User roles enumeration with string values."""
USER = "user"
ADMIN = "admin"

View File

@@ -0,0 +1,179 @@
"""
User models and validation for the MyDocManager application.
Contains Pydantic models for user creation, updates, database representation,
and API responses with proper validation and type safety.
"""
import re
from datetime import datetime
from typing import Optional, Any
from bson import ObjectId
from pydantic import BaseModel, Field, field_validator, EmailStr
from pydantic_core import core_schema
from app.models.auth import UserRole
class PyObjectId(ObjectId):
"""Custom ObjectId type for Pydantic v2 compatibility."""
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler
) -> core_schema.CoreSchema:
return core_schema.json_or_python_schema(
json_schema=core_schema.str_schema(),
python_schema=core_schema.union_schema([
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema([
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
])
]),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x)
),
)
@classmethod
def validate(cls, v):
if not ObjectId.is_valid(v):
raise ValueError("Invalid ObjectId")
return ObjectId(v)
def validate_password_strength(password: str) -> str:
"""
Validate password meets security requirements.
Requirements:
- At least 8 characters long
- Contains at least one uppercase letter
- Contains at least one lowercase letter
- Contains at least one digit
- Contains at least one special character
Args:
password: The password string to validate
Returns:
str: The validated password
Raises:
ValueError: If password doesn't meet requirements
"""
if len(password) < 8:
raise ValueError("Password must be at least 8 characters long")
if not re.search(r'[A-Z]', password):
raise ValueError("Password must contain at least one uppercase letter")
if not re.search(r'[a-z]', password):
raise ValueError("Password must contain at least one lowercase letter")
if not re.search(r'\d', password):
raise ValueError("Password must contain at least one digit")
if not re.search(r'[!@#$%^&*()_+\-=\[\]{};:"\\|,.<>\/?]', password):
raise ValueError("Password must contain at least one special character")
return password
def validate_username_not_empty(username: str) -> str:
"""
Validate username is not empty or whitespace only.
Args:
username: The username string to validate
Returns:
str: The validated username
Raises:
ValueError: If username is empty or whitespace only
"""
if not username or not username.strip():
raise ValueError("Username cannot be empty or whitespace only")
return username.strip()
class UserCreate(BaseModel):
"""Model for creating a new user."""
username: str
email: EmailStr
password: str
role: UserRole = UserRole.USER
@field_validator('username')
@classmethod
def validate_username(cls, v):
return validate_username_not_empty(v)
@field_validator('password')
@classmethod
def validate_password(cls, v):
return validate_password_strength(v)
class UserUpdate(BaseModel):
"""Model for updating an existing user."""
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
role: Optional[UserRole] = None
@field_validator('username')
@classmethod
def validate_username(cls, v):
if v is not None:
return validate_username_not_empty(v)
return v
@field_validator('password')
@classmethod
def validate_password(cls, v):
if v is not None:
return validate_password_strength(v)
return v
class UserInDB(BaseModel):
"""Model for user data stored in database."""
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
username: str
email: str
password_hash: str
role: UserRole
is_active: bool = True
created_at: datetime
updated_at: datetime
model_config = {
"populate_by_name": True,
"arbitrary_types_allowed": True,
"json_encoders": {ObjectId: str}
}
class UserResponse(BaseModel):
"""Model for user data in API responses (excludes password_hash)."""
id: PyObjectId = Field(alias="_id")
username: str
email: str
role: UserRole
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {
"populate_by_name": True,
"arbitrary_types_allowed": True,
"json_encoders": {ObjectId: str}
}

View File

View File

@@ -0,0 +1,74 @@
"""
Password security utilities using bcrypt for secure password hashing.
This module provides secure password hashing and verification functions
using the bcrypt algorithm with automatic salt generation.
"""
import bcrypt
from typing import Union
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt with automatic salt generation.
Args:
password: The plain text password to hash
Returns:
The hashed password as a string
Raises:
ValueError: If password is empty or None
RuntimeError: If bcrypt hashing fails
"""
if not password:
raise ValueError("Password cannot be empty or None")
try:
# Encode password to bytes and generate salt
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
# Hash the password
hashed = bcrypt.hashpw(password_bytes, salt)
# Return as string
return hashed.decode('utf-8')
except Exception as e:
raise RuntimeError(f"Failed to hash password: {str(e)}")
def verify_password(password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
password: The plain text password to verify
hashed_password: The hashed password to verify against
Returns:
True if password matches the hash, False otherwise
Raises:
ValueError: If password or hashed_password is empty or None
RuntimeError: If password verification fails due to malformed hash
"""
if not password or not hashed_password:
raise ValueError("Password and hashed_password cannot be empty or None")
try:
# Encode inputs to bytes
password_bytes = password.encode('utf-8')
hashed_bytes = hashed_password.encode('utf-8')
# Verify password
return bcrypt.checkpw(password_bytes, hashed_bytes)
except ValueError as e:
# bcrypt raises ValueError for malformed hashes
raise RuntimeError(f"Invalid hash format: {str(e)}")
except Exception as e:
raise RuntimeError(f"Failed to verify password: {str(e)}")

24
src/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
src/frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
src/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2808
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
src/frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.1.2"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
src/frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

35
src/frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,35 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

10
src/frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

194
tests/test_connection.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Unit tests for MongoDB database connection module.
Tests the database connection functionality with mocking
to avoid requiring actual MongoDB instance during tests.
"""
import pytest
from unittest.mock import Mock, patch, MagicMock
from pymongo.errors import ConnectionFailure, ServerSelectionTimeoutError
from app.database.connection import (
create_mongodb_client,
get_database,
close_database_connection,
get_mongodb_client,
test_database_connection
)
def test_i_can_get_database_connection():
"""Test successful database connection creation."""
mock_client = Mock()
mock_database = Mock()
mock_client.__getitem__.return_value = mock_database
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://localhost:27017"):
with patch('app.database.connection.get_mongodb_database_name', return_value="testdb"):
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
result = get_database()
assert result == mock_database
mock_client.admin.command.assert_called_with('ping')
def test_i_cannot_connect_to_invalid_mongodb_url():
"""Test fail-fast behavior with invalid MongoDB URL."""
mock_client = Mock()
mock_client.admin.command.side_effect = ConnectionFailure("Connection failed")
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://invalid:27017"):
with pytest.raises(SystemExit) as exc_info:
create_mongodb_client()
assert exc_info.value.code == 1
def test_i_cannot_connect_with_server_selection_timeout():
"""Test fail-fast behavior with server selection timeout."""
mock_client = Mock()
mock_client.admin.command.side_effect = ServerSelectionTimeoutError("Timeout")
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://timeout:27017"):
with pytest.raises(SystemExit) as exc_info:
create_mongodb_client()
assert exc_info.value.code == 1
def test_i_cannot_connect_with_unexpected_error():
"""Test fail-fast behavior with unexpected connection error."""
with patch('app.database.connection.MongoClient', side_effect=Exception("Unexpected error")):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://error:27017"):
with pytest.raises(SystemExit) as exc_info:
create_mongodb_client()
assert exc_info.value.code == 1
def test_i_can_get_database_singleton():
"""Test that get_database returns the same instance (singleton pattern)."""
mock_client = Mock()
mock_database = Mock()
mock_client.__getitem__.return_value = mock_database
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://localhost:27017"):
with patch('app.database.connection.get_mongodb_database_name', return_value="testdb"):
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
# First call
db1 = get_database()
# Second call
db2 = get_database()
assert db1 is db2
# MongoClient should be called only once
assert mock_client.admin.command.call_count == 1
def test_i_can_close_database_connection():
"""Test closing database connection."""
mock_client = Mock()
mock_database = Mock()
mock_client.__getitem__.return_value = mock_database
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://localhost:27017"):
with patch('app.database.connection.get_mongodb_database_name', return_value="testdb"):
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
# Create connection
get_database()
# Close connection
close_database_connection()
mock_client.close.assert_called_once()
assert app.database.connection._client is None
assert app.database.connection._database is None
def test_i_can_get_mongodb_client():
"""Test getting raw MongoDB client instance."""
mock_client = Mock()
mock_database = Mock()
mock_client.__getitem__.return_value = mock_database
with patch('app.database.connection.MongoClient', return_value=mock_client):
with patch('app.database.connection.get_mongodb_url', return_value="mongodb://localhost:27017"):
with patch('app.database.connection.get_mongodb_database_name', return_value="testdb"):
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
# Create connection first
get_database()
# Get client
result = get_mongodb_client()
assert result == mock_client
def test_i_can_get_none_mongodb_client_when_not_connected():
"""Test getting MongoDB client returns None when not connected."""
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
result = get_mongodb_client()
assert result is None
def test_i_can_test_database_connection_success():
"""Test database connection health check - success case."""
mock_database = Mock()
mock_database.command.return_value = True
with patch('app.database.connection.get_database', return_value=mock_database):
result = test_database_connection()
assert result is True
mock_database.command.assert_called_with('ping')
def test_i_cannot_test_database_connection_failure():
"""Test database connection health check - failure case."""
mock_database = Mock()
mock_database.command.side_effect = Exception("Connection error")
with patch('app.database.connection.get_database', return_value=mock_database):
result = test_database_connection()
assert result is False
def test_i_can_close_connection_when_no_client():
"""Test closing connection when no client exists (should not raise error)."""
# Reset global variables
import app.database.connection
app.database.connection._client = None
app.database.connection._database = None
# Should not raise any exception
close_database_connection()
assert app.database.connection._client is None
assert app.database.connection._database is None

105
tests/test_security.py Normal file
View File

@@ -0,0 +1,105 @@
"""
Unit tests for password security utilities.
Tests the bcrypt-based password hashing and verification functions
including edge cases and error handling.
"""
import pytest
from app.utils.security import hash_password, verify_password
def test_i_can_hash_password():
"""Test that a password is correctly hashed and different from original."""
password = "my_secure_password"
hashed = hash_password(password)
# Hash should be different from original password
assert hashed != password
# Hash should be a non-empty string
assert isinstance(hashed, str)
assert len(hashed) > 0
# Hash should start with bcrypt identifier
assert hashed.startswith("$2b$")
def test_same_password_generates_different_hashes():
"""Test that the salt generates different hashes for the same password."""
password = "identical_password"
hash1 = hash_password(password)
hash2 = hash_password(password)
# Same password should generate different hashes due to salt
assert hash1 != hash2
# But both should be valid bcrypt hashes
assert hash1.startswith("$2b$")
assert hash2.startswith("$2b$")
def test_i_can_verify_correct_password():
"""Test that a correct password is validated against its hash."""
password = "correct_password"
hashed = hash_password(password)
# Correct password should verify successfully
assert verify_password(password, hashed) is True
def test_i_cannot_verify_incorrect_password():
"""Test that an incorrect password is rejected."""
password = "correct_password"
wrong_password = "wrong_password"
hashed = hash_password(password)
# Wrong password should fail verification
assert verify_password(wrong_password, hashed) is False
def test_i_cannot_hash_empty_password():
"""Test that empty passwords are rejected during hashing."""
# Empty string should raise ValueError
with pytest.raises(ValueError, match="Password cannot be empty or None"):
hash_password("")
# None should raise ValueError
with pytest.raises(ValueError, match="Password cannot be empty or None"):
hash_password(None)
def test_i_cannot_verify_with_malformed_hash():
"""Test that malformed hashes are rejected during verification."""
password = "test_password"
malformed_hash = "not_a_valid_bcrypt_hash"
# Malformed hash should raise RuntimeError
with pytest.raises(RuntimeError, match="Invalid hash format"):
verify_password(password, malformed_hash)
def test_i_cannot_verify_with_none_values():
"""Test that None values are rejected during verification."""
password = "test_password"
hashed = hash_password(password)
# None password should raise ValueError
with pytest.raises(ValueError, match="Password and hashed_password cannot be empty or None"):
verify_password(None, hashed)
# None hash should raise ValueError
with pytest.raises(ValueError, match="Password and hashed_password cannot be empty or None"):
verify_password(password, None)
# Both None should raise ValueError
with pytest.raises(ValueError, match="Password and hashed_password cannot be empty or None"):
verify_password(None, None)
# Empty strings should also raise ValueError
with pytest.raises(ValueError, match="Password and hashed_password cannot be empty or None"):
verify_password("", hashed)
with pytest.raises(ValueError, match="Password and hashed_password cannot be empty or None"):
verify_password(password, "")

141
tests/test_settings.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Unit tests for configuration settings module.
Tests the environment variable loading and default values
for application configuration.
"""
import os
import pytest
from unittest.mock import patch
from app.config.settings import (
get_mongodb_url,
get_mongodb_database_name,
get_jwt_secret_key,
get_jwt_algorithm,
get_jwt_expire_hours,
is_development_environment
)
def test_i_can_load_mongodb_url_from_env():
"""Test loading MongoDB URL from environment variable."""
test_url = "mongodb://test-server:27017"
with patch.dict(os.environ, {"MONGODB_URL": test_url}):
result = get_mongodb_url()
assert result == test_url
def test_i_can_use_default_mongodb_url():
"""Test default MongoDB URL when environment variable is not set."""
with patch.dict(os.environ, {}, clear=True):
result = get_mongodb_url()
assert result == "mongodb://localhost:27017"
def test_i_can_load_mongodb_database_name_from_env():
"""Test loading MongoDB database name from environment variable."""
test_db_name = "test_database"
with patch.dict(os.environ, {"MONGODB_DATABASE": test_db_name}):
result = get_mongodb_database_name()
assert result == test_db_name
def test_i_can_use_default_mongodb_database_name():
"""Test default MongoDB database name when environment variable is not set."""
with patch.dict(os.environ, {}, clear=True):
result = get_mongodb_database_name()
assert result == "mydocmanager"
def test_i_can_load_jwt_secret_from_env():
"""Test loading JWT secret from environment variable."""
test_secret = "super-secret-key-123"
with patch.dict(os.environ, {"JWT_SECRET": test_secret}):
result = get_jwt_secret_key()
assert result == test_secret
def test_i_can_use_default_jwt_secret_in_development():
"""Test default JWT secret in development environment."""
with patch.dict(os.environ, {"ENVIRONMENT": "development"}, clear=True):
result = get_jwt_secret_key()
assert result == "dev-secret-key-change-in-production"
def test_i_cannot_get_jwt_secret_in_production_without_env():
"""Test that JWT secret raises error in production without environment variable."""
with patch.dict(os.environ, {"ENVIRONMENT": "production"}, clear=True):
with pytest.raises(ValueError, match="JWT_SECRET environment variable must be set in production"):
get_jwt_secret_key()
def test_i_can_load_jwt_algorithm_from_env():
"""Test loading JWT algorithm from environment variable."""
test_algorithm = "RS256"
with patch.dict(os.environ, {"JWT_ALGORITHM": test_algorithm}):
result = get_jwt_algorithm()
assert result == test_algorithm
def test_i_can_use_default_jwt_algorithm():
"""Test default JWT algorithm when environment variable is not set."""
with patch.dict(os.environ, {}, clear=True):
result = get_jwt_algorithm()
assert result == "HS256"
def test_i_can_load_jwt_expire_hours_from_env():
"""Test loading JWT expiration hours from environment variable."""
test_hours = "48"
with patch.dict(os.environ, {"JWT_EXPIRE_HOURS": test_hours}):
result = get_jwt_expire_hours()
assert result == 48
def test_i_can_use_default_jwt_expire_hours():
"""Test default JWT expiration hours when environment variable is not set."""
with patch.dict(os.environ, {}, clear=True):
result = get_jwt_expire_hours()
assert result == 24
def test_i_can_handle_invalid_jwt_expire_hours():
"""Test handling of invalid JWT expiration hours value."""
with patch.dict(os.environ, {"JWT_EXPIRE_HOURS": "invalid"}):
result = get_jwt_expire_hours()
assert result == 24 # Should fall back to default
def test_i_can_detect_development_environment():
"""Test detection of development environment."""
with patch.dict(os.environ, {"ENVIRONMENT": "development"}):
result = is_development_environment()
assert result is True
def test_i_can_detect_production_environment():
"""Test detection of production environment."""
with patch.dict(os.environ, {"ENVIRONMENT": "production"}):
result = is_development_environment()
assert result is False
def test_i_can_use_default_development_environment():
"""Test default environment is development."""
with patch.dict(os.environ, {}, clear=True):
result = is_development_environment()
assert result is True
def test_i_can_handle_case_insensitive_environment():
"""Test case insensitive environment detection."""
with patch.dict(os.environ, {"ENVIRONMENT": "DEVELOPMENT"}):
result = is_development_environment()
assert result is True

382
tests/test_user_models.py Normal file
View File

@@ -0,0 +1,382 @@
"""
Unit tests for user models and validation.
Tests the Pydantic models used for user creation, update, and response.
Validates email format, password strength, and data serialization.
"""
import pytest
from pydantic import ValidationError
from datetime import datetime
from bson import ObjectId
from app.models.user import UserCreate, UserUpdate, UserInDB, UserResponse
from app.models.auth import UserRole
class TestUserCreateModel:
"""Tests for UserCreate Pydantic model validation."""
def test_i_can_create_user_create_model(self):
"""Test creation of valid UserCreate model with all required fields."""
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": "TestPass123!",
"role": UserRole.USER
}
user = UserCreate(**user_data)
assert user.username == "testuser"
assert user.email == "test@example.com"
assert user.password == "TestPass123!"
assert user.role == UserRole.USER
def test_i_can_create_admin_user(self):
"""Test creation of admin user with admin role."""
user_data = {
"username": "adminuser",
"email": "admin@example.com",
"password": "AdminPass123!",
"role": UserRole.ADMIN
}
user = UserCreate(**user_data)
assert user.role == UserRole.ADMIN
def test_i_cannot_create_user_with_invalid_email(self):
"""Test that invalid email formats are rejected."""
invalid_emails = [
"notanemail",
"@example.com",
"test@",
"test.example.com",
"",
"test@.com"
]
for invalid_email in invalid_emails:
user_data = {
"username": "testuser",
"email": invalid_email,
"password": "TestPass123!",
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "email" in str(exc_info.value).lower()
def test_i_cannot_create_user_with_short_password(self):
"""Test that passwords shorter than 8 characters are rejected."""
short_passwords = [
"Test1!", # 6 chars
"Te1!", # 4 chars
"1234567", # 7 chars
"" # empty
]
for short_password in short_passwords:
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": short_password,
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "password" in str(exc_info.value).lower()
def test_i_cannot_create_user_without_uppercase(self):
"""Test that passwords without uppercase letters are rejected."""
passwords_without_uppercase = [
"testpass123!",
"mypassword1!",
"lowercase123!"
]
for password in passwords_without_uppercase:
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": password,
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "password" in str(exc_info.value).lower()
def test_i_cannot_create_user_without_lowercase(self):
"""Test that passwords without lowercase letters are rejected."""
passwords_without_lowercase = [
"TESTPASS123!",
"MYPASSWORD1!",
"UPPERCASE123!"
]
for password in passwords_without_lowercase:
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": password,
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "password" in str(exc_info.value).lower()
def test_i_cannot_create_user_without_digit(self):
"""Test that passwords without digits are rejected."""
passwords_without_digit = [
"TestPassword!",
"MyPassword!",
"UpperLower!"
]
for password in passwords_without_digit:
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": password,
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "password" in str(exc_info.value).lower()
def test_i_cannot_create_user_without_special_character(self):
"""Test that passwords without special characters are rejected."""
passwords_without_special = [
"TestPass123",
"MyPassword1",
"UpperLower1"
]
for password in passwords_without_special:
user_data = {
"username": "testuser",
"email": "test@example.com",
"password": password,
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "password" in str(exc_info.value).lower()
def test_i_cannot_create_user_with_empty_username(self):
"""Test that empty username is rejected."""
user_data = {
"username": "",
"email": "test@example.com",
"password": "TestPass123!",
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "username" in str(exc_info.value).lower()
def test_i_cannot_create_user_with_whitespace_username(self):
"""Test that username with only whitespace is rejected."""
whitespace_usernames = [" ", "\t", "\n", " \t\n "]
for username in whitespace_usernames:
user_data = {
"username": username,
"email": "test@example.com",
"password": "TestPass123!",
"role": UserRole.USER
}
with pytest.raises(ValidationError) as exc_info:
UserCreate(**user_data)
assert "username" in str(exc_info.value).lower()
class TestUserUpdateModel:
"""Tests for UserUpdate Pydantic model validation."""
def test_i_can_create_user_update_model(self):
"""Test creation of valid UserUpdate model with optional fields."""
update_data = {
"email": "newemail@example.com",
"role": UserRole.ADMIN
}
user_update = UserUpdate(**update_data)
assert user_update.email == "newemail@example.com"
assert user_update.role == UserRole.ADMIN
assert user_update.username is None
assert user_update.password is None
def test_i_can_create_empty_user_update_model(self):
"""Test creation of UserUpdate model with no fields (all optional)."""
user_update = UserUpdate()
assert user_update.username is None
assert user_update.email is None
assert user_update.password is None
assert user_update.role is None
def test_i_can_update_password_with_valid_format(self):
"""Test that valid password can be used in update."""
update_data = {
"password": "NewPass123!"
}
user_update = UserUpdate(**update_data)
assert user_update.password == "NewPass123!"
def test_i_cannot_update_with_invalid_password(self):
"""Test that invalid password format is rejected in update."""
update_data = {
"password": "weak"
}
with pytest.raises(ValidationError) as exc_info:
UserUpdate(**update_data)
assert "password" in str(exc_info.value).lower()
class TestUserInDBModel:
"""Tests for UserInDB Pydantic model (database representation)."""
def test_i_can_create_user_in_db_model(self):
"""Test creation of valid UserInDB model with all fields."""
user_id = ObjectId()
created_at = datetime.utcnow()
updated_at = datetime.utcnow()
user_data = {
"id": user_id,
"username": "testuser",
"email": "test@example.com",
"password_hash": "$2b$12$hashedpassword",
"role": UserRole.USER,
"is_active": True,
"created_at": created_at,
"updated_at": updated_at
}
user = UserInDB(**user_data)
assert user.id == user_id
assert user.username == "testuser"
assert user.email == "test@example.com"
assert user.password_hash == "$2b$12$hashedpassword"
assert user.role == UserRole.USER
assert user.is_active is True
assert user.created_at == created_at
assert user.updated_at == updated_at
def test_i_can_create_inactive_user(self):
"""Test creation of inactive user."""
user_data = {
"id": ObjectId(),
"username": "testuser",
"email": "test@example.com",
"password_hash": "$2b$12$hashedpassword",
"role": UserRole.USER,
"is_active": False,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
user = UserInDB(**user_data)
assert user.is_active is False
class TestUserResponseModel:
"""Tests for UserResponse Pydantic model (API response)."""
def test_i_can_create_user_response_model(self):
"""Test creation of valid UserResponse model without password."""
user_id = ObjectId()
created_at = datetime.utcnow()
updated_at = datetime.utcnow()
user_data = {
"id": user_id,
"username": "testuser",
"email": "test@example.com",
"role": UserRole.USER,
"is_active": True,
"created_at": created_at,
"updated_at": updated_at
}
user = UserResponse(**user_data)
assert user.id == user_id
assert user.username == "testuser"
assert user.email == "test@example.com"
assert user.role == UserRole.USER
assert user.is_active is True
assert user.created_at == created_at
assert user.updated_at == updated_at
# Verify password_hash is not included
assert not hasattr(user, 'password_hash')
def test_user_response_excludes_password_hash(self):
"""Test that UserResponse model does not expose password_hash."""
# This test verifies the model structure doesn't include password_hash
response_fields = UserResponse.__fields__.keys()
assert 'password_hash' not in response_fields
assert 'username' in response_fields
assert 'email' in response_fields
assert 'role' in response_fields
assert 'is_active' in response_fields
def test_i_can_convert_user_in_db_to_response(self):
"""Test conversion from UserInDB to UserResponse model."""
user_id = ObjectId()
created_at = datetime.utcnow()
updated_at = datetime.utcnow()
user_in_db = UserInDB(
id=user_id,
username="testuser",
email="test@example.com",
password_hash="$2b$12$hashedpassword",
role=UserRole.USER,
is_active=True,
created_at=created_at,
updated_at=updated_at
)
# Convert to response model (excluding password_hash)
user_response = UserResponse(
id=user_in_db.id,
username=user_in_db.username,
email=user_in_db.email,
role=user_in_db.role,
is_active=user_in_db.is_active,
created_at=user_in_db.created_at,
updated_at=user_in_db.updated_at
)
assert user_response.id == user_in_db.id
assert user_response.username == user_in_db.username
assert user_response.email == user_in_db.email
assert user_response.role == user_in_db.role
assert not hasattr(user_response, 'password_hash')

View File

@@ -0,0 +1,385 @@
"""
Unit tests for user repository module.
Tests all CRUD operations for users with MongoDB mocking
to ensure proper database interactions without requiring
actual MongoDB instance during tests.
"""
import pytest
from unittest.mock import Mock, MagicMock
from datetime import datetime
from bson import ObjectId
from pymongo.errors import DuplicateKeyError
from app.database.repositories.user_repository import UserRepository
from app.models.user import UserCreate, UserUpdate, UserInDB, UserRole
@pytest.fixture
def mock_database():
"""Create mock database with users collection."""
db = Mock()
collection = Mock()
db.users = collection
return db
@pytest.fixture
def user_repository(mock_database):
"""Create UserRepository instance with mocked database."""
return UserRepository(mock_database)
@pytest.fixture
def sample_user_create():
"""Create sample UserCreate object for testing."""
return UserCreate(
username="testuser",
email="test@example.com",
hashed_password="hashed_password_123",
role=UserRole.USER,
is_active=True
)
@pytest.fixture
def sample_user_update():
"""Create sample UserUpdate object for testing."""
return UserUpdate(
email="updated@example.com",
role=UserRole.ADMIN,
is_active=False
)
def test_i_can_create_user(user_repository, mock_database, sample_user_create):
"""Test successful user creation."""
# Mock successful insertion
mock_result = Mock()
mock_result.inserted_id = ObjectId()
mock_database.users.insert_one.return_value = mock_result
result = user_repository.create_user(sample_user_create)
assert isinstance(result, UserInDB)
assert result.username == sample_user_create.username
assert result.email == sample_user_create.email
assert result.hashed_password == sample_user_create.hashed_password
assert result.role == sample_user_create.role
assert result.is_active == sample_user_create.is_active
assert result.id is not None
assert isinstance(result.created_at, datetime)
assert isinstance(result.updated_at, datetime)
# Verify insert_one was called with correct data
mock_database.users.insert_one.assert_called_once()
call_args = mock_database.users.insert_one.call_args[0][0]
assert call_args["username"] == sample_user_create.username
assert call_args["email"] == sample_user_create.email
def test_i_cannot_create_duplicate_username(user_repository, mock_database, sample_user_create):
"""Test that creating user with duplicate username raises DuplicateKeyError."""
# Mock DuplicateKeyError from MongoDB
mock_database.users.insert_one.side_effect = DuplicateKeyError("duplicate key error")
with pytest.raises(DuplicateKeyError, match="User with username 'testuser' already exists"):
user_repository.create_user(sample_user_create)
def test_i_can_find_user_by_username(user_repository, mock_database):
"""Test finding user by username."""
# Mock user document from database
user_doc = {
"_id": ObjectId(),
"username": "testuser",
"email": "test@example.com",
"hashed_password": "hashed_password_123",
"role": "user",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
mock_database.users.find_one.return_value = user_doc
result = user_repository.find_user_by_username("testuser")
assert isinstance(result, UserInDB)
assert result.username == "testuser"
assert result.email == "test@example.com"
mock_database.users.find_one.assert_called_once_with({"username": "testuser"})
def test_i_cannot_find_nonexistent_user_by_username(user_repository, mock_database):
"""Test finding nonexistent user by username returns None."""
mock_database.users.find_one.return_value = None
result = user_repository.find_user_by_username("nonexistent")
assert result is None
mock_database.users.find_one.assert_called_once_with({"username": "nonexistent"})
def test_i_can_find_user_by_id(user_repository, mock_database):
"""Test finding user by ID."""
user_id = ObjectId()
user_doc = {
"_id": user_id,
"username": "testuser",
"email": "test@example.com",
"hashed_password": "hashed_password_123",
"role": "user",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
mock_database.users.find_one.return_value = user_doc
result = user_repository.find_user_by_id(str(user_id))
assert isinstance(result, UserInDB)
assert result.id == user_id
assert result.username == "testuser"
mock_database.users.find_one.assert_called_once_with({"_id": user_id})
def test_i_cannot_find_user_with_invalid_id(user_repository, mock_database):
"""Test finding user with invalid ObjectId returns None."""
result = user_repository.find_user_by_id("invalid_id")
assert result is None
# find_one should not be called with invalid ID
mock_database.users.find_one.assert_not_called()
def test_i_cannot_find_nonexistent_user_by_id(user_repository, mock_database):
"""Test finding nonexistent user by ID returns None."""
user_id = ObjectId()
mock_database.users.find_one.return_value = None
result = user_repository.find_user_by_id(str(user_id))
assert result is None
mock_database.users.find_one.assert_called_once_with({"_id": user_id})
def test_i_can_find_user_by_email(user_repository, mock_database):
"""Test finding user by email address."""
user_doc = {
"_id": ObjectId(),
"username": "testuser",
"email": "test@example.com",
"hashed_password": "hashed_password_123",
"role": "user",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
mock_database.users.find_one.return_value = user_doc
result = user_repository.find_user_by_email("test@example.com")
assert isinstance(result, UserInDB)
assert result.email == "test@example.com"
mock_database.users.find_one.assert_called_once_with({"email": "test@example.com"})
def test_i_can_update_user(user_repository, mock_database, sample_user_update):
"""Test updating user information."""
user_id = ObjectId()
# Mock successful update
mock_update_result = Mock()
mock_update_result.matched_count = 1
mock_database.users.update_one.return_value = mock_update_result
# Mock find_one for returning updated user
updated_user_doc = {
"_id": user_id,
"username": "testuser",
"email": "updated@example.com",
"hashed_password": "hashed_password_123",
"role": "admin",
"is_active": False,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
mock_database.users.find_one.return_value = updated_user_doc
result = user_repository.update_user(str(user_id), sample_user_update)
assert isinstance(result, UserInDB)
assert result.email == "updated@example.com"
assert result.role == UserRole.ADMIN
assert result.is_active is False
# Verify update_one was called with correct data
mock_database.users.update_one.assert_called_once()
call_args = mock_database.users.update_one.call_args
assert call_args[0][0] == {"_id": user_id} # Filter
update_data = call_args[0][1]["$set"] # Update data
assert update_data["email"] == "updated@example.com"
assert update_data["role"] == UserRole.ADMIN
assert update_data["is_active"] is False
assert "updated_at" in update_data
def test_i_cannot_update_nonexistent_user(user_repository, mock_database, sample_user_update):
"""Test updating nonexistent user returns None."""
user_id = ObjectId()
# Mock no match found
mock_update_result = Mock()
mock_update_result.matched_count = 0
mock_database.users.update_one.return_value = mock_update_result
result = user_repository.update_user(str(user_id), sample_user_update)
assert result is None
def test_i_cannot_update_user_with_invalid_id(user_repository, mock_database, sample_user_update):
"""Test updating user with invalid ID returns None."""
result = user_repository.update_user("invalid_id", sample_user_update)
assert result is None
# update_one should not be called with invalid ID
mock_database.users.update_one.assert_not_called()
def test_i_can_delete_user(user_repository, mock_database):
"""Test successful user deletion."""
user_id = ObjectId()
# Mock successful deletion
mock_delete_result = Mock()
mock_delete_result.deleted_count = 1
mock_database.users.delete_one.return_value = mock_delete_result
result = user_repository.delete_user(str(user_id))
assert result is True
mock_database.users.delete_one.assert_called_once_with({"_id": user_id})
def test_i_cannot_delete_nonexistent_user(user_repository, mock_database):
"""Test deleting nonexistent user returns False."""
user_id = ObjectId()
# Mock no deletion occurred
mock_delete_result = Mock()
mock_delete_result.deleted_count = 0
mock_database.users.delete_one.return_value = mock_delete_result
result = user_repository.delete_user(str(user_id))
assert result is False
def test_i_cannot_delete_user_with_invalid_id(user_repository, mock_database):
"""Test deleting user with invalid ID returns False."""
result = user_repository.delete_user("invalid_id")
assert result is False
# delete_one should not be called with invalid ID
mock_database.users.delete_one.assert_not_called()
def test_i_can_list_users(user_repository, mock_database):
"""Test listing users with pagination."""
# Mock cursor with user documents
user_docs = [
{
"_id": ObjectId(),
"username": "user1",
"email": "user1@example.com",
"hashed_password": "hash1",
"role": "user",
"is_active": True,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
},
{
"_id": ObjectId(),
"username": "user2",
"email": "user2@example.com",
"hashed_password": "hash2",
"role": "admin",
"is_active": False,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow()
}
]
mock_cursor = Mock()
mock_cursor.__iter__.return_value = iter(user_docs)
mock_cursor.skip.return_value = mock_cursor
mock_cursor.limit.return_value = mock_cursor
mock_database.users.find.return_value = mock_cursor
result = user_repository.list_users(skip=10, limit=50)
assert len(result) == 2
assert all(isinstance(user, UserInDB) for user in result)
assert result[0].username == "user1"
assert result[1].username == "user2"
mock_database.users.find.assert_called_once()
mock_cursor.skip.assert_called_once_with(10)
mock_cursor.limit.assert_called_once_with(50)
def test_i_can_count_users(user_repository, mock_database):
"""Test counting total users."""
mock_database.users.count_documents.return_value = 42
result = user_repository.count_users()
assert result == 42
mock_database.users.count_documents.assert_called_once_with({})
def test_i_can_check_user_exists(user_repository, mock_database):
"""Test checking if user exists by username."""
mock_database.users.count_documents.return_value = 1
result = user_repository.user_exists("testuser")
assert result is True
mock_database.users.count_documents.assert_called_once_with({"username": "testuser"})
def test_i_can_check_user_does_not_exist(user_repository, mock_database):
"""Test checking if user does not exist by username."""
mock_database.users.count_documents.return_value = 0
result = user_repository.user_exists("nonexistent")
assert result is False
mock_database.users.count_documents.assert_called_once_with({"username": "nonexistent"})
def test_i_can_create_indexes_on_initialization(mock_database):
"""Test that indexes are created when repository is initialized."""
# Mock create_index to not raise exception
mock_database.users.create_index.return_value = None
repository = UserRepository(mock_database)
mock_database.users.create_index.assert_called_once_with("username", unique=True)
def test_i_can_handle_index_creation_error(mock_database):
"""Test that index creation errors are handled gracefully."""
# Mock create_index to raise exception (index already exists)
mock_database.users.create_index.side_effect = Exception("Index already exists")
# Should not raise exception
repository = UserRepository(mock_database)
assert repository is not None
mock_database.users.create_index.assert_called_once_with("username", unique=True)