diff --git a/docs/stories/story-1.5-cli-user-management.md b/docs/stories/story-1.5-cli-user-management.md new file mode 100644 index 0000000..7e3ca16 --- /dev/null +++ b/docs/stories/story-1.5-cli-user-management.md @@ -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 + diff --git a/src/cli/commands.py b/src/cli/commands.py index 7409db3..76a6907 100644 --- a/src/cli/commands.py +++ b/src/cli/commands.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index 3708f54..d2af2e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/integration/test_cli_integration.py b/tests/integration/test_cli_integration.py new file mode 100644 index 0000000..2ed78d4 --- /dev/null +++ b/tests/integration/test_cli_integration.py @@ -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 + diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py new file mode 100644 index 0000000..b0077db --- /dev/null +++ b/tests/unit/test_cli_commands.py @@ -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 +