feat: Story 1.5 - CLI User Management
- Add CLI commands for user management (add-user, delete-user, list-users) - Implement admin authentication for all user management commands - Add password confirmation and deletion safety checks - Prevent admins from deleting their own accounts - Create 17 unit tests with mocked dependencies - Create 18 integration tests with real database - Add shared db_session fixture in conftest.py - Update CLI commands to use db_manager for session handling - Complete story documentation with usage examples All 35 tests passing. Story 1.5 complete.main
parent
0a223e2fc5
commit
b6e495e9fe
|
|
@ -0,0 +1,440 @@
|
|||
# Story 1.5: Command-Line User Management - COMPLETED
|
||||
|
||||
## Overview
|
||||
Implemented comprehensive CLI commands for user management with admin authentication, including commands to add, delete, and list users through a command-line interface.
|
||||
|
||||
## Story Details
|
||||
**As an Admin**, I want command-line tools to add and remove users, so that I can manage system access for the MVP.
|
||||
|
||||
## Acceptance Criteria - ALL MET
|
||||
|
||||
### 1. CLI Command to Create Users
|
||||
**Status:** COMPLETE
|
||||
|
||||
A CLI command exists to create new users with specified username, password, and role:
|
||||
- Command: `add-user`
|
||||
- Options: `--username`, `--password`, `--role`, `--admin-user`, `--admin-password`
|
||||
- Roles: Admin or User (case-sensitive)
|
||||
- Password confirmation prompt built-in
|
||||
- Returns success message with created user details
|
||||
|
||||
### 2. CLI Command to Delete Users
|
||||
**Status:** COMPLETE
|
||||
|
||||
A CLI command exists to delete users by their username:
|
||||
- Command: `delete-user`
|
||||
- Options: `--username`, `--admin-user`, `--admin-password`
|
||||
- Safety confirmation prompt ("Are you sure?")
|
||||
- Prevents admin from deleting their own account
|
||||
- Returns success message after deletion
|
||||
|
||||
### 3. Commands Require Admin Authentication
|
||||
**Status:** COMPLETE
|
||||
|
||||
All user management commands require admin authentication:
|
||||
- Admin credentials prompted if not provided via options
|
||||
- Authentication verified against database with hashed passwords
|
||||
- Non-admin users cannot execute user management commands
|
||||
- Returns appropriate error messages for auth failures
|
||||
|
||||
### 4. Appropriate Feedback Provided
|
||||
**Status:** COMPLETE
|
||||
|
||||
Commands provide clear feedback to the console:
|
||||
- Success messages for successful operations
|
||||
- Error messages for failures (auth, validation, duplicates)
|
||||
- User-friendly prompts for required inputs
|
||||
- Proper exit codes (0 for success, 1 for failure)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Files Created/Modified
|
||||
|
||||
#### 1. `src/cli/commands.py` - UPDATED
|
||||
```python
|
||||
New Functions:
|
||||
- authenticate_admin(username, password) -> Optional[User]
|
||||
- Authenticates user and verifies admin role
|
||||
- Returns User object if admin, None otherwise
|
||||
|
||||
- prompt_admin_credentials() -> tuple[str, str]
|
||||
- Prompts for admin username and password
|
||||
- Hides password input for security
|
||||
|
||||
New Commands:
|
||||
- add-user: Create a new user
|
||||
- delete-user: Delete an existing user
|
||||
- list-users: List all users (bonus command)
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- HTTP Basic Authentication against database
|
||||
- Role-based access control (Admin only)
|
||||
- Interactive prompts for missing credentials
|
||||
- Comprehensive error handling
|
||||
- Session management with proper cleanup
|
||||
|
||||
#### 2. `tests/unit/test_cli_commands.py` - NEW
|
||||
```python
|
||||
Test coverage for CLI commands:
|
||||
- TestAuthenticateAdmin: 3 tests
|
||||
- TestAddUserCommand: 4 tests
|
||||
- TestDeleteUserCommand: 4 tests
|
||||
- TestListUsersCommand: 3 tests
|
||||
- TestExistingCommands: 3 tests
|
||||
Total: 17 unit tests
|
||||
```
|
||||
|
||||
**Test Coverage:**
|
||||
- Mocked dependencies for isolated testing
|
||||
- Authentication flow testing
|
||||
- Role-based access control verification
|
||||
- Error handling scenarios
|
||||
- All edge cases covered
|
||||
|
||||
#### 3. `tests/integration/test_cli_integration.py` - NEW
|
||||
```python
|
||||
Integration tests with real database:
|
||||
- TestAddUserIntegration: 5 tests
|
||||
- TestDeleteUserIntegration: 4 tests
|
||||
- TestListUsersIntegration: 4 tests
|
||||
- TestUserManagementWorkflow: 2 tests
|
||||
- TestExistingCommandsIntegration: 3 tests
|
||||
Total: 18 integration tests
|
||||
```
|
||||
|
||||
**Integration Features:**
|
||||
- Real database interactions
|
||||
- Full authentication flow
|
||||
- Multi-user scenarios
|
||||
- Complete lifecycle testing
|
||||
- Security verification
|
||||
|
||||
#### 4. `tests/conftest.py` - UPDATED
|
||||
Added shared `db_session` fixture for all integration tests:
|
||||
- Creates in-memory SQLite database
|
||||
- Initializes schema automatically
|
||||
- Provides clean session per test
|
||||
- Automatic cleanup after tests
|
||||
|
||||
### Commands
|
||||
|
||||
#### Add User Command
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Interactive mode (prompts for all inputs)
|
||||
python main.py add-user
|
||||
|
||||
# With credentials provided
|
||||
python main.py add-user --username newuser --password pass123 --role User --admin-user admin --admin-password adminpass
|
||||
|
||||
# Mixed mode (prompts for missing inputs)
|
||||
python main.py add-user --username newuser --role User
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--username`: Username for the new user (required)
|
||||
- `--password`: Password for the new user (required, with confirmation)
|
||||
- `--role`: Role for the new user - Admin or User (required)
|
||||
- `--admin-user`: Admin username for authentication (optional, prompts if missing)
|
||||
- `--admin-password`: Admin password for authentication (optional, prompts if missing)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Create a regular user
|
||||
python main.py add-user --username john --password secure123 --role User --admin-user admin --admin-password adminpass
|
||||
# Output: Success: User 'john' created with role 'User'
|
||||
|
||||
# Create an admin user
|
||||
python main.py add-user --username newadmin --password admin456 --role Admin --admin-user admin --admin-password adminpass
|
||||
# Output: Success: User 'newadmin' created with role 'Admin'
|
||||
```
|
||||
|
||||
**Error Cases:**
|
||||
- Duplicate username: "Error: User with username 'X' already exists"
|
||||
- Invalid admin credentials: "Error: Authentication failed or insufficient permissions"
|
||||
- Invalid role: Must be 'Admin' or 'User'
|
||||
|
||||
#### Delete User Command
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Interactive mode
|
||||
python main.py delete-user
|
||||
|
||||
# With credentials provided
|
||||
python main.py delete-user --username olduser --admin-user admin --admin-password adminpass --yes
|
||||
|
||||
# Prompts for confirmation
|
||||
python main.py delete-user --username olduser
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--username`: Username to delete (required)
|
||||
- `--admin-user`: Admin username for authentication (optional, prompts if missing)
|
||||
- `--admin-password`: Admin password for authentication (optional, prompts if missing)
|
||||
- `--yes`: Skip confirmation prompt
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Delete a user
|
||||
python main.py delete-user --username john --admin-user admin --admin-password adminpass --yes
|
||||
# Output: Success: User 'john' has been deleted
|
||||
|
||||
# Try to delete non-existent user
|
||||
python main.py delete-user --username nonexistent --admin-user admin --admin-password adminpass --yes
|
||||
# Output: Error: User 'nonexistent' not found
|
||||
```
|
||||
|
||||
**Safety Features:**
|
||||
- Confirmation prompt: "Are you sure you want to delete this user?"
|
||||
- Cannot delete own account: "Error: Cannot delete your own account"
|
||||
- User must exist: "Error: User 'X' not found"
|
||||
|
||||
#### List Users Command (Bonus)
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# List all users
|
||||
python main.py list-users --admin-user admin --admin-password adminpass
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `--admin-user`: Admin username for authentication (optional, prompts if missing)
|
||||
- `--admin-password`: Admin password for authentication (optional, prompts if missing)
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
Total users: 3
|
||||
------------------------------------------------------------
|
||||
ID Username Role Created
|
||||
------------------------------------------------------------
|
||||
1 admin Admin 2024-01-15 10:30:45
|
||||
2 john User 2024-01-15 14:22:10
|
||||
3 jane User 2024-01-15 15:45:33
|
||||
------------------------------------------------------------
|
||||
```
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
1. User invokes command with or without admin credentials
|
||||
2. If credentials not provided, system prompts for them
|
||||
3. `authenticate_admin()` validates credentials:
|
||||
- Creates database session
|
||||
- Retrieves user by username
|
||||
- Verifies password using bcrypt
|
||||
- Checks user has Admin role
|
||||
4. On success: Command executes with new database session
|
||||
5. On failure: Error message displayed, command aborts
|
||||
|
||||
### Security Features
|
||||
|
||||
1. **Password Hashing**: All passwords hashed with bcrypt before storage
|
||||
2. **Hidden Input**: Password prompts hide input from console
|
||||
3. **Confirmation Prompts**: Password confirmation on creation, deletion confirmation
|
||||
4. **Role-Based Access**: Only Admin role can manage users
|
||||
5. **Self-Protection**: Admins cannot delete their own accounts
|
||||
6. **Secure Sessions**: Database sessions properly closed after operations
|
||||
7. **Clear Error Messages**: Generic errors prevent information leakage
|
||||
|
||||
### Test Coverage
|
||||
|
||||
#### Unit Tests (17 tests, all passing)
|
||||
```bash
|
||||
# Run unit tests
|
||||
.venv/Scripts/python -m pytest tests/unit/test_cli_commands.py -v
|
||||
|
||||
Test Results:
|
||||
- test_authenticate_admin_success: PASSED
|
||||
- test_authenticate_admin_not_admin_role: PASSED
|
||||
- test_authenticate_admin_invalid_credentials: PASSED
|
||||
- test_add_user_success_with_admin_credentials: PASSED
|
||||
- test_add_user_authentication_fails: PASSED
|
||||
- test_add_user_duplicate_username: PASSED
|
||||
- test_add_user_prompts_for_credentials: PASSED
|
||||
- test_delete_user_success: PASSED
|
||||
- test_delete_user_not_found: PASSED
|
||||
- test_delete_user_cannot_delete_self: PASSED
|
||||
- test_delete_user_authentication_fails: PASSED
|
||||
- test_list_users_success: PASSED
|
||||
- test_list_users_empty: PASSED
|
||||
- test_list_users_authentication_fails: PASSED
|
||||
- test_config_command_success: PASSED
|
||||
- test_health_command_success: PASSED
|
||||
- test_models_command_success: PASSED
|
||||
```
|
||||
|
||||
#### Integration Tests (18 tests, all passing)
|
||||
```bash
|
||||
# Run integration tests
|
||||
.venv/Scripts/python -m pytest tests/integration/test_cli_integration.py -v
|
||||
|
||||
Test Results:
|
||||
- test_add_user_with_admin_auth: PASSED
|
||||
- test_add_admin_user: PASSED
|
||||
- test_add_user_with_duplicate_username: PASSED
|
||||
- test_add_user_with_invalid_admin_password: PASSED
|
||||
- test_add_user_with_regular_user_auth_fails: PASSED
|
||||
- test_delete_user_success: PASSED
|
||||
- test_delete_user_not_found: PASSED
|
||||
- test_delete_own_account_fails: PASSED
|
||||
- test_delete_user_with_regular_user_fails: PASSED
|
||||
- test_list_users_with_admin: PASSED
|
||||
- test_list_multiple_users: PASSED
|
||||
- test_list_users_with_regular_user_fails: PASSED
|
||||
- test_list_users_shows_creation_date: PASSED
|
||||
- test_complete_user_lifecycle: PASSED
|
||||
- test_multiple_admins_can_manage_users: PASSED
|
||||
- test_config_command_integration: PASSED
|
||||
- test_health_command_integration: PASSED
|
||||
- test_models_command_integration: PASSED
|
||||
```
|
||||
|
||||
### Testing Commands
|
||||
|
||||
```bash
|
||||
# Run all CLI tests
|
||||
.venv/Scripts/python -m pytest tests/unit/test_cli_commands.py tests/integration/test_cli_integration.py -v
|
||||
|
||||
# Run unit tests only
|
||||
.venv/Scripts/python -m pytest tests/unit/test_cli_commands.py -v
|
||||
|
||||
# Run integration tests only
|
||||
.venv/Scripts/python -m pytest tests/integration/test_cli_integration.py -v
|
||||
|
||||
# Run with coverage
|
||||
.venv/Scripts/python -m pytest tests/unit/test_cli_commands.py tests/integration/test_cli_integration.py --cov=src/cli --cov-report=html
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `click==8.1.7` - Command-line interface framework
|
||||
- `sqlalchemy==2.0.23` - Database ORM
|
||||
- `passlib[bcrypt]==1.7.4` - Password hashing
|
||||
- `bcrypt==4.0.1` - Bcrypt implementation
|
||||
- `pytest==8.4.2` - Testing framework
|
||||
- `pytest-mock==3.15.1` - Mocking support for tests
|
||||
|
||||
## Next Steps
|
||||
|
||||
This CLI user management foundation is now ready for:
|
||||
- **Story 1.6**: FQDN Bucket Mapping Management (CLI commands for domain mappings)
|
||||
- **Story 1.7**: CI/CD Pipeline Setup (automated testing in pipeline)
|
||||
- **Epic 2**: Content Generation features will use user authentication
|
||||
- Integration with future admin dashboard/web interface
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Typical Workflow
|
||||
|
||||
```bash
|
||||
# 1. Initial admin creates first user
|
||||
python main.py add-user
|
||||
# Prompts: username, password, role, admin-user, admin-password
|
||||
|
||||
# 2. Admin lists all users
|
||||
python main.py list-users --admin-user admin --admin-password adminpass
|
||||
|
||||
# 3. Admin creates content creator user
|
||||
python main.py add-user \
|
||||
--username content_creator \
|
||||
--password secure456 \
|
||||
--role User \
|
||||
--admin-user admin \
|
||||
--admin-password adminpass
|
||||
|
||||
# 4. Admin deletes old user
|
||||
python main.py delete-user \
|
||||
--username olduser \
|
||||
--admin-user admin \
|
||||
--admin-password adminpass \
|
||||
--yes
|
||||
|
||||
# 5. Verify changes
|
||||
python main.py list-users --admin-user admin --admin-password adminpass
|
||||
```
|
||||
|
||||
### Script Usage
|
||||
|
||||
For automated user provisioning:
|
||||
|
||||
```bash
|
||||
# provision-users.sh
|
||||
#!/bin/bash
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS="$ADMIN_PASSWORD" # From environment variable
|
||||
|
||||
# Create multiple users
|
||||
python main.py add-user --username user1 --password pass1 --role User --admin-user $ADMIN_USER --admin-password $ADMIN_PASS
|
||||
python main.py add-user --username user2 --password pass2 --role User --admin-user $ADMIN_USER --admin-password $ADMIN_PASS
|
||||
python main.py add-user --username user3 --password pass3 --role User --admin-user $ADMIN_USER --admin-password $ADMIN_PASS
|
||||
|
||||
echo "Users provisioned successfully"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- CLI commands use the same database and authentication as the API
|
||||
- Password confirmation prevents typos during user creation
|
||||
- Admin authentication is required for every command execution (stateless)
|
||||
- Commands are designed for both interactive and scripted use
|
||||
- All operations are atomic (succeed or fail completely)
|
||||
- Sessions are properly managed to prevent database locks
|
||||
- The `list-users` command was added as a bonus feature for better user management
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
**Why Click?**
|
||||
- Industry-standard CLI framework for Python
|
||||
- Excellent support for options, prompts, and confirmations
|
||||
- Built-in password hiding for security
|
||||
- Easy to test with CliRunner
|
||||
- Great documentation and community support
|
||||
|
||||
**Why Require Admin Auth Per Command?**
|
||||
- Stateless design (no session management needed)
|
||||
- More secure (credentials not stored)
|
||||
- Works well for scripting and automation
|
||||
- Simpler implementation for MVP
|
||||
- Can be enhanced with session tokens later
|
||||
|
||||
**Why Separate Database Sessions?**
|
||||
- Authentication and operations use separate sessions
|
||||
- Prevents transaction conflicts
|
||||
- Proper cleanup and error handling
|
||||
- Follows best practices for database access
|
||||
- Testable with mocking
|
||||
|
||||
**Why Prevent Self-Deletion?**
|
||||
- Prevents accidental lockout
|
||||
- Ensures at least one admin always exists
|
||||
- Common security best practice
|
||||
- Can be overridden with special admin tools if needed
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
- [x] Add-user command implemented
|
||||
- [x] Delete-user command implemented
|
||||
- [x] List-users command implemented (bonus)
|
||||
- [x] Admin authentication required
|
||||
- [x] Interactive prompts for credentials
|
||||
- [x] Password confirmation on creation
|
||||
- [x] Deletion confirmation prompt
|
||||
- [x] Self-deletion prevention
|
||||
- [x] Proper error handling
|
||||
- [x] Success/failure feedback
|
||||
- [x] Unit tests written and passing (17 tests)
|
||||
- [x] Integration tests written and passing (18 tests)
|
||||
- [x] Shared test fixtures in conftest.py
|
||||
- [x] Story documentation completed
|
||||
|
||||
## Bonus Features Added
|
||||
|
||||
- **List Users Command**: Provides visibility into existing users
|
||||
- **Password Confirmation**: Built-in double-entry for passwords
|
||||
- **Self-Protection**: Prevents admins from deleting themselves
|
||||
- **Flexible Authentication**: Can provide credentials via options or prompts
|
||||
- **Formatted Output**: Clean, table-formatted user listings
|
||||
- **Comprehensive Testing**: 35 total tests covering all scenarios
|
||||
|
||||
|
|
@ -3,7 +3,49 @@ CLI command definitions using Click
|
|||
"""
|
||||
|
||||
import click
|
||||
from core.config import get_config
|
||||
from typing import Optional
|
||||
from src.core.config import get_config
|
||||
from src.auth.service import AuthService
|
||||
from src.database.session import db_manager
|
||||
from src.database.repositories import UserRepository
|
||||
from src.database.models import User
|
||||
|
||||
|
||||
def authenticate_admin(username: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Authenticate a user and verify they have admin role
|
||||
|
||||
Args:
|
||||
username: The username to authenticate
|
||||
password: The password to authenticate
|
||||
|
||||
Returns:
|
||||
User object if authenticated and is admin, None otherwise
|
||||
"""
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
user_repo = UserRepository(session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
user = auth_service.authenticate_user(username, password)
|
||||
if user and user.is_admin():
|
||||
return user
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def prompt_admin_credentials() -> tuple[str, str]:
|
||||
"""
|
||||
Prompt for admin username and password
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password)
|
||||
"""
|
||||
click.echo("Admin authentication required")
|
||||
username = click.prompt("Username", type=str)
|
||||
password = click.prompt("Password", type=str, hide_input=True)
|
||||
return username, password
|
||||
|
||||
|
||||
@click.group()
|
||||
|
|
@ -57,5 +99,145 @@ def models():
|
|||
click.echo(f"Error listing models: {e}", err=True)
|
||||
|
||||
|
||||
@app.command("add-user")
|
||||
@click.option("--username", prompt=True, help="Username for the new user")
|
||||
@click.option("--password", prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help="Password for the new user")
|
||||
@click.option("--role", type=click.Choice(["Admin", "User"], case_sensitive=True),
|
||||
prompt=True, help="Role for the new user")
|
||||
@click.option("--admin-user", help="Admin username for authentication")
|
||||
@click.option("--admin-password", help="Admin password for authentication")
|
||||
def add_user(username: str, password: str, role: str,
|
||||
admin_user: Optional[str], admin_password: Optional[str]):
|
||||
"""Create a new user (requires admin authentication)"""
|
||||
try:
|
||||
# Authenticate admin
|
||||
if not admin_user or not admin_password:
|
||||
admin_user, admin_password = prompt_admin_credentials()
|
||||
|
||||
admin = authenticate_admin(admin_user, admin_password)
|
||||
if not admin:
|
||||
click.echo("Error: Authentication failed or insufficient permissions", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# Create the new user
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
user_repo = UserRepository(session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
new_user = auth_service.create_user_with_hashed_password(
|
||||
username=username,
|
||||
password=password,
|
||||
role=role
|
||||
)
|
||||
|
||||
click.echo(f"Success: User '{new_user.username}' created with role '{new_user.role}'")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
click.echo(f"Error creating user: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@app.command("delete-user")
|
||||
@click.option("--username", prompt=True, help="Username to delete")
|
||||
@click.option("--admin-user", help="Admin username for authentication")
|
||||
@click.option("--admin-password", help="Admin password for authentication")
|
||||
@click.confirmation_option(prompt="Are you sure you want to delete this user?")
|
||||
def delete_user(username: str, admin_user: Optional[str],
|
||||
admin_password: Optional[str]):
|
||||
"""Delete a user by username (requires admin authentication)"""
|
||||
try:
|
||||
# Authenticate admin
|
||||
if not admin_user or not admin_password:
|
||||
admin_user, admin_password = prompt_admin_credentials()
|
||||
|
||||
admin = authenticate_admin(admin_user, admin_password)
|
||||
if not admin:
|
||||
click.echo("Error: Authentication failed or insufficient permissions", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# Prevent admin from deleting themselves
|
||||
if admin.username == username:
|
||||
click.echo("Error: Cannot delete your own account", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# Delete the user
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
user_repo = UserRepository(session)
|
||||
|
||||
# Check if user exists
|
||||
user_to_delete = user_repo.get_by_username(username)
|
||||
if not user_to_delete:
|
||||
click.echo(f"Error: User '{username}' not found", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# Delete the user
|
||||
success = user_repo.delete(user_to_delete.id)
|
||||
if success:
|
||||
click.echo(f"Success: User '{username}' has been deleted")
|
||||
else:
|
||||
click.echo(f"Error: Failed to delete user '{username}'", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error deleting user: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
@app.command("list-users")
|
||||
@click.option("--admin-user", help="Admin username for authentication")
|
||||
@click.option("--admin-password", help="Admin password for authentication")
|
||||
def list_users(admin_user: Optional[str], admin_password: Optional[str]):
|
||||
"""List all users (requires admin authentication)"""
|
||||
try:
|
||||
# Authenticate admin
|
||||
if not admin_user or not admin_password:
|
||||
admin_user, admin_password = prompt_admin_credentials()
|
||||
|
||||
admin = authenticate_admin(admin_user, admin_password)
|
||||
if not admin:
|
||||
click.echo("Error: Authentication failed or insufficient permissions", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
# List all users
|
||||
session = db_manager.get_session()
|
||||
try:
|
||||
user_repo = UserRepository(session)
|
||||
users = user_repo.get_all()
|
||||
|
||||
if not users:
|
||||
click.echo("No users found")
|
||||
return
|
||||
|
||||
click.echo(f"\nTotal users: {len(users)}")
|
||||
click.echo("-" * 60)
|
||||
click.echo(f"{'ID':<5} {'Username':<20} {'Role':<10} {'Created'}")
|
||||
click.echo("-" * 60)
|
||||
|
||||
for user in users:
|
||||
created = user.created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
click.echo(f"{user.id:<5} {user.username:<20} {user.role:<10} {created}")
|
||||
|
||||
click.echo("-" * 60)
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error listing users: {e}", err=True)
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
|
|||
|
|
@ -1 +1,19 @@
|
|||
# Pytest fixtures
|
||||
"""
|
||||
Pytest fixtures for all tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from src.database.models import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
"""Create an in-memory SQLite database for testing"""
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,421 @@
|
|||
"""
|
||||
Integration tests for CLI commands with real database
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from click.testing import CliRunner
|
||||
from src.cli.commands import app
|
||||
from src.database.session import db_manager
|
||||
from src.database.repositories import UserRepository
|
||||
from src.auth.service import AuthService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner():
|
||||
"""Fixture providing a CLI runner"""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_db_manager(db_session):
|
||||
"""Mock db_manager to use test database session for all CLI integration tests"""
|
||||
with patch('src.cli.commands.db_manager') as mock_manager:
|
||||
# Make db_manager.get_session() return our test session
|
||||
mock_manager.get_session.return_value = db_session
|
||||
yield mock_manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_admin_user(db_session):
|
||||
"""Fixture to create an admin user for testing"""
|
||||
user_repo = UserRepository(db_session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
# Create admin user
|
||||
admin = auth_service.create_user_with_hashed_password(
|
||||
username="testadmin",
|
||||
password="adminpass123",
|
||||
role="Admin"
|
||||
)
|
||||
|
||||
yield admin
|
||||
|
||||
# Cleanup is handled by conftest.py's db_session fixture
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup_regular_user(db_session):
|
||||
"""Fixture to create a regular user for testing"""
|
||||
user_repo = UserRepository(db_session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
# Create regular user
|
||||
user = auth_service.create_user_with_hashed_password(
|
||||
username="testuser",
|
||||
password="userpass123",
|
||||
role="User"
|
||||
)
|
||||
|
||||
yield user
|
||||
|
||||
|
||||
class TestAddUserIntegration:
|
||||
"""Integration tests for add-user command"""
|
||||
|
||||
def test_add_user_with_admin_auth(self, cli_runner, setup_admin_user, db_session):
|
||||
"""Test adding a user with valid admin authentication"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'newuser',
|
||||
'--password', 'newpass123',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Success: User 'newuser' created with role 'User'" in result.output
|
||||
|
||||
# Verify user was created in database
|
||||
user_repo = UserRepository(db_session)
|
||||
new_user = user_repo.get_by_username('newuser')
|
||||
assert new_user is not None
|
||||
assert new_user.username == 'newuser'
|
||||
assert new_user.role == 'User'
|
||||
|
||||
def test_add_admin_user(self, cli_runner, setup_admin_user, db_session):
|
||||
"""Test adding an admin user"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'newadmin',
|
||||
'--password', 'adminpass456',
|
||||
'--role', 'Admin',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Success: User 'newadmin' created with role 'Admin'" in result.output
|
||||
|
||||
# Verify admin user was created
|
||||
user_repo = UserRepository(db_session)
|
||||
new_admin = user_repo.get_by_username('newadmin')
|
||||
assert new_admin is not None
|
||||
assert new_admin.is_admin()
|
||||
|
||||
def test_add_user_with_duplicate_username(self, cli_runner, setup_admin_user):
|
||||
"""Test adding a user with an existing username"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'testadmin',
|
||||
'--password', 'somepass',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "User with username 'testadmin' already exists" in result.output
|
||||
|
||||
def test_add_user_with_invalid_admin_password(self, cli_runner, setup_admin_user):
|
||||
"""Test adding a user with wrong admin password"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'someuser',
|
||||
'--password', 'somepass',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'wrongpass'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
def test_add_user_with_regular_user_auth_fails(self, cli_runner, setup_regular_user):
|
||||
"""Test that regular users cannot add users"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'someuser',
|
||||
'--password', 'somepass',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'testuser',
|
||||
'--admin-password', 'userpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
|
||||
class TestDeleteUserIntegration:
|
||||
"""Integration tests for delete-user command"""
|
||||
|
||||
def test_delete_user_success(self, cli_runner, setup_admin_user, setup_regular_user, db_session):
|
||||
"""Test deleting a user successfully"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'testuser',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Success: User 'testuser' has been deleted" in result.output
|
||||
|
||||
# Verify user was deleted from database
|
||||
user_repo = UserRepository(db_session)
|
||||
deleted_user = user_repo.get_by_username('testuser')
|
||||
assert deleted_user is None
|
||||
|
||||
def test_delete_user_not_found(self, cli_runner, setup_admin_user):
|
||||
"""Test deleting a non-existent user"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'nonexistent',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "User 'nonexistent' not found" in result.output
|
||||
|
||||
def test_delete_own_account_fails(self, cli_runner, setup_admin_user, db_session):
|
||||
"""Test that admin cannot delete their own account"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'testadmin',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Cannot delete your own account" in result.output
|
||||
|
||||
# Verify admin still exists
|
||||
user_repo = UserRepository(db_session)
|
||||
admin = user_repo.get_by_username('testadmin')
|
||||
assert admin is not None
|
||||
|
||||
def test_delete_user_with_regular_user_fails(self, cli_runner, setup_regular_user,
|
||||
db_session):
|
||||
"""Test that regular users cannot delete users"""
|
||||
# Create another user to try to delete
|
||||
user_repo = UserRepository(db_session)
|
||||
auth_service = AuthService(user_repo)
|
||||
auth_service.create_user_with_hashed_password(
|
||||
username="targetuser",
|
||||
password="targetpass",
|
||||
role="User"
|
||||
)
|
||||
|
||||
result = cli_runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'targetuser',
|
||||
'--admin-user', 'testuser',
|
||||
'--admin-password', 'userpass123',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
|
||||
class TestListUsersIntegration:
|
||||
"""Integration tests for list-users command"""
|
||||
|
||||
def test_list_users_with_admin(self, cli_runner, setup_admin_user):
|
||||
"""Test listing users as admin"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Total users: 1" in result.output
|
||||
assert "testadmin" in result.output
|
||||
assert "Admin" in result.output
|
||||
|
||||
def test_list_multiple_users(self, cli_runner, setup_admin_user, db_session):
|
||||
"""Test listing multiple users"""
|
||||
# Create additional users
|
||||
user_repo = UserRepository(db_session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
auth_service.create_user_with_hashed_password(
|
||||
username="user1",
|
||||
password="pass1",
|
||||
role="User"
|
||||
)
|
||||
auth_service.create_user_with_hashed_password(
|
||||
username="user2",
|
||||
password="pass2",
|
||||
role="User"
|
||||
)
|
||||
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Total users: 3" in result.output
|
||||
assert "testadmin" in result.output
|
||||
assert "user1" in result.output
|
||||
assert "user2" in result.output
|
||||
|
||||
def test_list_users_with_regular_user_fails(self, cli_runner, setup_regular_user):
|
||||
"""Test that regular users cannot list users"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testuser',
|
||||
'--admin-password', 'userpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
def test_list_users_shows_creation_date(self, cli_runner, setup_admin_user):
|
||||
"""Test that list-users shows creation date"""
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
|
||||
assert result.exit_code == 0
|
||||
# Check for date format (YYYY-MM-DD HH:MM:SS)
|
||||
import re
|
||||
assert re.search(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', result.output)
|
||||
|
||||
|
||||
class TestUserManagementWorkflow:
|
||||
"""Integration tests for complete user management workflows"""
|
||||
|
||||
def test_complete_user_lifecycle(self, cli_runner, setup_admin_user):
|
||||
"""Test creating, listing, and deleting a user"""
|
||||
# Create user
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'lifecycle_user',
|
||||
'--password', 'password123',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# List users - should show 2 users
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "Total users: 2" in result.output
|
||||
assert "lifecycle_user" in result.output
|
||||
|
||||
# Delete user
|
||||
result = cli_runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'lifecycle_user',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123',
|
||||
'--yes'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# List users again - should show 1 user
|
||||
result = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'testadmin',
|
||||
'--admin-password', 'adminpass123'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "Total users: 1" in result.output
|
||||
assert "lifecycle_user" not in result.output
|
||||
|
||||
def test_multiple_admins_can_manage_users(self, cli_runner, db_session):
|
||||
"""Test that multiple admins can independently manage users"""
|
||||
# Create first admin
|
||||
user_repo = UserRepository(db_session)
|
||||
auth_service = AuthService(user_repo)
|
||||
|
||||
admin1 = auth_service.create_user_with_hashed_password(
|
||||
username="admin1",
|
||||
password="admin1pass",
|
||||
role="Admin"
|
||||
)
|
||||
|
||||
# Admin1 creates a second admin
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'admin2',
|
||||
'--password', 'admin2pass',
|
||||
'--role', 'Admin',
|
||||
'--admin-user', 'admin1',
|
||||
'--admin-password', 'admin1pass'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Admin2 creates a regular user
|
||||
result = cli_runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'regularuser',
|
||||
'--password', 'regularpass',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'admin2',
|
||||
'--admin-password', 'admin2pass'
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
|
||||
# Both admins can list users
|
||||
result1 = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'admin1',
|
||||
'--admin-password', 'admin1pass'
|
||||
])
|
||||
assert result1.exit_code == 0
|
||||
assert "Total users: 3" in result1.output
|
||||
|
||||
result2 = cli_runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'admin2',
|
||||
'--admin-password', 'admin2pass'
|
||||
])
|
||||
assert result2.exit_code == 0
|
||||
assert "Total users: 3" in result2.output
|
||||
|
||||
|
||||
class TestExistingCommandsIntegration:
|
||||
"""Integration tests for existing commands (config, health, models)"""
|
||||
|
||||
def test_config_command_integration(self, cli_runner):
|
||||
"""Test config command with real configuration"""
|
||||
result = cli_runner.invoke(app, ['config'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Current Configuration:" in result.output
|
||||
assert "Application:" in result.output
|
||||
assert "Database:" in result.output
|
||||
|
||||
def test_health_command_integration(self, cli_runner):
|
||||
"""Test health command with real system"""
|
||||
result = cli_runner.invoke(app, ['health'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "[OK] Configuration loaded successfully" in result.output
|
||||
assert "[OK] System is healthy" in result.output
|
||||
|
||||
def test_models_command_integration(self, cli_runner):
|
||||
"""Test models command with real configuration"""
|
||||
result = cli_runner.invoke(app, ['models'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Available AI Models:" in result.output
|
||||
assert "Provider:" in result.output
|
||||
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
"""
|
||||
Unit tests for CLI commands
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from click.testing import CliRunner
|
||||
from datetime import datetime
|
||||
from src.cli.commands import app, authenticate_admin, prompt_admin_credentials
|
||||
from src.database.models import User
|
||||
|
||||
|
||||
class TestAuthenticateAdmin:
|
||||
"""Tests for authenticate_admin function"""
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
def test_authenticate_admin_success(self, mock_db_manager):
|
||||
"""Test successful admin authentication"""
|
||||
# Setup
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_user = User(
|
||||
id=1,
|
||||
username="admin",
|
||||
hashed_password="hashed_password",
|
||||
role="Admin",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.authenticate_user.return_value = mock_user
|
||||
|
||||
# Execute
|
||||
result = authenticate_admin("admin", "password")
|
||||
|
||||
# Assert
|
||||
assert result == mock_user
|
||||
mock_auth.authenticate_user.assert_called_once_with("admin", "password")
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
def test_authenticate_admin_not_admin_role(self, mock_db_manager):
|
||||
"""Test authentication fails when user is not admin"""
|
||||
# Setup
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_user = User(
|
||||
id=1,
|
||||
username="user",
|
||||
hashed_password="hashed_password",
|
||||
role="User",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.authenticate_user.return_value = mock_user
|
||||
|
||||
# Execute
|
||||
result = authenticate_admin("user", "password")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
def test_authenticate_admin_invalid_credentials(self, mock_db_manager):
|
||||
"""Test authentication fails with invalid credentials"""
|
||||
# Setup
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.authenticate_user.return_value = None
|
||||
|
||||
# Execute
|
||||
result = authenticate_admin("admin", "wrongpassword")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
class TestAddUserCommand:
|
||||
"""Tests for add-user CLI command"""
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_add_user_success_with_admin_credentials(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test successfully adding a user with admin credentials provided"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_new_user = Mock(username="newuser", role="User")
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.create_user_with_hashed_password.return_value = mock_new_user
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'newuser',
|
||||
'--password', 'password123',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "Success: User 'newuser' created with role 'User'" in result.output
|
||||
mock_auth_admin.assert_called_once_with('admin', 'adminpass')
|
||||
mock_auth.create_user_with_hashed_password.assert_called_once_with(
|
||||
username='newuser',
|
||||
password='password123',
|
||||
role='User'
|
||||
)
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_add_user_authentication_fails(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test add-user fails when admin authentication fails"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_auth_admin.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'newuser',
|
||||
'--password', 'password123',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'wrongpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_add_user_duplicate_username(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test add-user fails when username already exists"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.create_user_with_hashed_password.side_effect = ValueError(
|
||||
"User with username 'existinguser' already exists"
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'existinguser',
|
||||
'--password', 'password123',
|
||||
'--role', 'User',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "User with username 'existinguser' already exists" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
@patch('src.cli.commands.prompt_admin_credentials')
|
||||
def test_add_user_prompts_for_credentials(self, mock_prompt, mock_auth_admin,
|
||||
mock_db_manager):
|
||||
"""Test add-user prompts for admin credentials when not provided"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_prompt.return_value = ('admin', 'adminpass')
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_new_user = Mock(username="newuser", role="User")
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
|
||||
with patch('src.cli.commands.AuthService') as mock_auth_class:
|
||||
mock_auth = Mock()
|
||||
mock_auth_class.return_value = mock_auth
|
||||
mock_auth.create_user_with_hashed_password.return_value = mock_new_user
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'add-user',
|
||||
'--username', 'newuser',
|
||||
'--password', 'password123',
|
||||
'--role', 'User'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
mock_prompt.assert_called_once()
|
||||
mock_auth_admin.assert_called_once_with('admin', 'adminpass')
|
||||
|
||||
|
||||
class TestDeleteUserCommand:
|
||||
"""Tests for delete-user CLI command"""
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_delete_user_success(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test successfully deleting a user"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_user_to_delete = Mock(id=2, username="deleteuser")
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
mock_repo.get_by_username.return_value = mock_user_to_delete
|
||||
mock_repo.delete.return_value = True
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'deleteuser',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "Success: User 'deleteuser' has been deleted" in result.output
|
||||
mock_repo.get_by_username.assert_called_once_with('deleteuser')
|
||||
mock_repo.delete.assert_called_once_with(2)
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_delete_user_not_found(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test delete-user fails when user doesn't exist"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
mock_repo.get_by_username.return_value = None
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'nonexistent',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "User 'nonexistent' not found" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_delete_user_cannot_delete_self(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test admin cannot delete their own account"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'admin',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "Cannot delete your own account" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_delete_user_authentication_fails(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test delete-user fails when admin authentication fails"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_auth_admin.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'delete-user',
|
||||
'--username', 'someuser',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'wrongpass',
|
||||
'--yes'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
|
||||
class TestListUsersCommand:
|
||||
"""Tests for list-users CLI command"""
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_list_users_success(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test successfully listing users"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
mock_users = [
|
||||
Mock(id=1, username="admin", role="Admin",
|
||||
created_at=datetime(2024, 1, 1, 12, 0, 0)),
|
||||
Mock(id=2, username="user1", role="User",
|
||||
created_at=datetime(2024, 1, 2, 12, 0, 0)),
|
||||
Mock(id=3, username="user2", role="User",
|
||||
created_at=datetime(2024, 1, 3, 12, 0, 0))
|
||||
]
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
mock_repo.get_all.return_value = mock_users
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "Total users: 3" in result.output
|
||||
assert "admin" in result.output
|
||||
assert "user1" in result.output
|
||||
assert "user2" in result.output
|
||||
assert "Admin" in result.output
|
||||
assert "User" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_list_users_empty(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test listing users when no users exist"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_admin = Mock(username="admin", role="Admin")
|
||||
mock_auth_admin.return_value = mock_admin
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
with patch('src.cli.commands.UserRepository') as mock_repo_class:
|
||||
mock_repo = Mock()
|
||||
mock_repo_class.return_value = mock_repo
|
||||
mock_repo.get_all.return_value = []
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'adminpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "No users found" in result.output
|
||||
|
||||
@patch('src.cli.commands.db_manager')
|
||||
@patch('src.cli.commands.authenticate_admin')
|
||||
def test_list_users_authentication_fails(self, mock_auth_admin, mock_db_manager):
|
||||
"""Test list-users fails when admin authentication fails"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_auth_admin.return_value = None
|
||||
|
||||
mock_session = Mock()
|
||||
mock_db_manager.get_session.return_value = mock_session
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, [
|
||||
'list-users',
|
||||
'--admin-user', 'admin',
|
||||
'--admin-password', 'wrongpass'
|
||||
])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 1
|
||||
assert "Authentication failed or insufficient permissions" in result.output
|
||||
|
||||
|
||||
class TestExistingCommands:
|
||||
"""Tests for existing CLI commands (config, health, models)"""
|
||||
|
||||
@patch('src.cli.commands.get_config')
|
||||
def test_config_command_success(self, mock_get_config):
|
||||
"""Test config command displays configuration"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_config = Mock()
|
||||
mock_config.application.name = "Test App"
|
||||
mock_config.application.version = "1.0.0"
|
||||
mock_config.application.environment = "test"
|
||||
mock_config.database.url = "sqlite:///test.db"
|
||||
mock_config.ai_service.model = "test-model"
|
||||
mock_config.logging.level = "INFO"
|
||||
mock_get_config.return_value = mock_config
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, ['config'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "Test App" in result.output
|
||||
assert "1.0.0" in result.output
|
||||
assert "test" in result.output
|
||||
|
||||
@patch('src.cli.commands.get_config')
|
||||
def test_health_command_success(self, mock_get_config):
|
||||
"""Test health command shows system is healthy"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_config = Mock()
|
||||
mock_get_config.return_value = mock_config
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, ['health'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "[OK] Configuration loaded successfully" in result.output
|
||||
assert "[OK] System is healthy" in result.output
|
||||
|
||||
@patch('src.cli.commands.get_config')
|
||||
def test_models_command_success(self, mock_get_config):
|
||||
"""Test models command lists available models"""
|
||||
# Setup
|
||||
runner = CliRunner()
|
||||
mock_config = Mock()
|
||||
mock_config.ai_service.model = "test-model-1"
|
||||
mock_config.ai_service.provider = "test-provider"
|
||||
mock_config.ai_service.base_url = "https://test.api"
|
||||
mock_config.ai_service.available_models = {
|
||||
"model1": "test-model-1",
|
||||
"model2": "test-model-2"
|
||||
}
|
||||
mock_get_config.return_value = mock_config
|
||||
|
||||
# Execute
|
||||
result = runner.invoke(app, ['models'])
|
||||
|
||||
# Assert
|
||||
assert result.exit_code == 0
|
||||
assert "test-provider" in result.output
|
||||
assert "test-model-1" in result.output
|
||||
assert "test-model-2" in result.output
|
||||
|
||||
Loading…
Reference in New Issue