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
PeninsulaInd 2025-10-18 11:13:45 -05:00
parent 0a223e2fc5
commit b6e495e9fe
5 changed files with 1595 additions and 2 deletions

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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