diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e94edf --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# Git Manager + +A web-based management platform for Gitea server mirrors and SSH keys, providing centralized control over Git server synchronization and key management. + +## Features + +- **SSH Key Management** + - Create, read, update, and delete SSH keys + - AES-256 encryption for stored private keys + - Secure key storage and retrieval + +- **Server Management** + - Add and manage Gitea server configurations + - Server health monitoring + - Automatic synchronization support + +- **Repository Synchronization** + - Mirror repositories from Gitea servers + - Sync history and logging + - Scheduled sync operations + +- **Web Interface** + - Modern Vue.js-based frontend + - Element Plus UI components + - Real-time status monitoring + +- **REST API** + - Full-featured REST API + - Token-based authentication + - OpenAPI documentation + +## Quick Start + +### Prerequisites + +- Python 3.8+ +- Node.js 16+ +- Git + +### Installation + +1. Clone the repository: +```bash +git clone +cd git +``` + +2. Install backend dependencies: +```bash +cd backend +pip install -r requirements.txt +``` + +3. Install frontend dependencies: +```bash +cd ../frontend +npm install +``` + +### Initialization + +1. Create environment configuration: +```bash +cp .env.example .env +``` + +2. Edit `.env` with your configuration: +```bash +# Generate a secure encryption key (32 bytes, base64 encoded) +python -c "import base64, os; print(base64.b64encode(os.urandom(32)).decode())" + +# Generate a secure API token +python -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +3. Initialize the database: +```bash +cd backend +python init_db.py +``` + +### Build and Run + +#### Option 1: Using the start script (Linux/Mac) +```bash +./start.sh +``` + +#### Option 2: Manual startup + +1. Build the frontend: +```bash +cd frontend +npm run build +``` + +2. Start the backend server: +```bash +cd ../backend +python -m app.main +``` + +Or using uvicorn directly: +```bash +cd backend +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +3. Access the application: + - Web UI: http://localhost:8000 + - API Documentation: http://localhost:8000/api/docs + - ReDoc: http://localhost:8000/api/redoc + +## API Documentation + +### Authentication + +All API endpoints require authentication via the `X-API-Token` header: + +```bash +curl -H "X-API-Token: your-api-token" http://localhost:8000/api/servers +``` + +### Main Endpoints + +#### Status +- `GET /api/status` - Get system status + +#### SSH Keys +- `GET /api/ssh-keys` - List all SSH keys +- `POST /api/ssh-keys` - Create a new SSH key +- `GET /api/ssh-keys/{key_id}` - Get SSH key details +- `PUT /api/ssh-keys/{key_id}` - Update SSH key +- `DELETE /api/ssh-keys/{key_id}` - Delete SSH key + +#### Servers +- `GET /api/servers` - List all servers +- `POST /api/servers` - Add a new server +- `GET /api/servers/{server_id}` - Get server details +- `PUT /api/servers/{server_id}` - Update server +- `DELETE /api/servers/{server_id}` - Delete server +- `POST /api/servers/{server_id}/sync` - Trigger server sync + +For detailed API documentation, visit `/api/docs` when the server is running. + +## Development Setup + +### Backend Development + +1. Install development dependencies: +```bash +cd backend +pip install -r requirements.txt +``` + +2. Run tests: +```bash +pytest +``` + +3. Run with auto-reload: +```bash +uvicorn app.main:app --reload +``` + +### Frontend Development + +1. Start development server: +```bash +cd frontend +npm run dev +``` + +2. Build for production: +```bash +npm run build +``` + +3. Preview production build: +```bash +npm run preview +``` + +## Project Structure + +``` +git/ +├── backend/ # FastAPI backend +│ ├── app/ +│ │ ├── api/ # API route handlers +│ │ ├── models/ # SQLAlchemy ORM models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── services/ # Business logic +│ │ ├── config.py # Configuration management +│ │ ├── database.py # Database connection +│ │ ├── security.py # Encryption & auth +│ │ └── main.py # FastAPI application +│ ├── tests/ # Backend tests +│ ├── init_db.py # Database initialization script +│ └── requirements.txt # Python dependencies +├── frontend/ # Vue.js frontend +│ ├── src/ +│ │ ├── api/ # API client +│ │ ├── components/ # Vue components +│ │ ├── router/ # Vue Router config +│ │ ├── stores/ # Pinia stores +│ │ └── main.js # App entry point +│ ├── package.json # Node dependencies +│ └── vite.config.js # Vite config +├── data/ # Application data +│ ├── database.db # SQLite database +│ ├── ssh-keys/ # Stored SSH keys +│ └── repos/ # Mirrored repositories +├── docs/ # Documentation +├── tests/ # Integration tests +├── .env.example # Environment template +├── start.sh # Startup script +└── README.md # This file +``` + +## Configuration + +Configuration is managed through environment variables in the `.env` file: + +| Variable | Description | Default | +|----------|-------------|---------| +| `GM_ENCRYPT_KEY` | AES-256 encryption key (base64) | Required | +| `GM_API_TOKEN` | API authentication token | Required | +| `GM_DATA_DIR` | Data directory path | `./data` | +| `GM_HOST` | Server host | `0.0.0.0` | +| `GM_PORT` | Server port | `8000` | + +## Tech Stack + +### Backend +- **FastAPI** - Modern, fast web framework +- **SQLAlchemy** - ORM for database operations +- **Pydantic** - Data validation using Python type annotations +- **Uvicorn** - ASGI server +- **APScheduler** - Task scheduling +- **Paramiko** - SSH/SCP operations +- **GitPython** - Git operations +- **Cryptography** - AES-256 encryption + +### Frontend +- **Vue.js 3** - Progressive JavaScript framework +- **Vite** - Build tool and dev server +- **Vue Router** - Official router +- **Pinia** - State management +- **Element Plus** - Vue 3 UI library +- **Axios** - HTTP client + +### Database +- **SQLite** - Lightweight database (easily replaceable with PostgreSQL/MySQL) + +## Security + +- SSH private keys are encrypted using AES-256-GCM +- API authentication via secure tokens +- CORS support for cross-origin requests +- Input validation and sanitization + +## License + +[Your License Here] + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/backend/PHASE_5_6_IMPLEMENTATION_REPORT.md b/backend/PHASE_5_6_IMPLEMENTATION_REPORT.md new file mode 100644 index 0000000..354c4b6 --- /dev/null +++ b/backend/PHASE_5_6_IMPLEMENTATION_REPORT.md @@ -0,0 +1,404 @@ +# Phase 5 & 6 Implementation Report + +## Overview + +This report documents the implementation of Phase 5 (API Routes) and Phase 6 (FastAPI Main Application) for the Git Manager project. + +--- + +## Phase 5: API Routes + +### Task 5.1: API Dependencies (`app/api/deps.py`) + +**File:** `C:\electron\git\backend\app\api\deps.py` + +**Implemented Functions:** + +1. **`get_db_session()`** + - Dependency for database session management + - Returns SQLAlchemy session generator + - Raises 500 error if database not initialized + - Automatically handles session cleanup + +2. **`require_auth()`** + - Authentication dependency for protected endpoints + - Validates Bearer token using `verify_api_token()` + - Raises 401 if missing or invalid credentials + - Returns None on successful authentication + +3. **`require_auth_optional()`** + - Optional authentication dependency + - Returns True if authenticated, False otherwise + - Useful for endpoints with different behavior based on auth status + +**Security Features:** +- Uses HTTPBearer security scheme +- Token validation through `verify_api_token()` +- Standardized error responses (401 Unauthorized) + +--- + +### Task 5.2: SSH Keys API (`app/api/ssh_keys.py`) + +**File:** `C:\electron\git\backend\app\api\ssh_keys.py` + +**Implemented Endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/ssh-keys` | Create new SSH key | +| GET | `/api/ssh-keys` | List all SSH keys | +| GET | `/api/ssh-keys/{id}` | Get specific SSH key | +| DELETE | `/api/ssh-keys/{id}` | Delete SSH key | + +**Features:** +- All endpoints require authentication +- Private keys are encrypted before storage +- Name uniqueness validation +- SSH key format validation +- Usage check before deletion (prevents deletion if in use by servers) +- Standard response format using `SuccessResponse` wrapper + +**Error Handling:** +- 400: Validation errors (duplicate name, invalid key format, key in use) +- 401: Authentication failures +- 404: SSH key not found + +--- + +### Task 5.3: Servers API (`app/api/servers.py`) + +**File:** `C:\electron\git\backend\app\api\servers.py` + +**Implemented Endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/api/servers` | Create new server | +| GET | `/api/servers` | List all servers | +| GET | `/api/servers/{id}` | Get specific server | +| PUT | `/api/servers/{id}` | Update server | +| DELETE | `/api/servers/{id}` | Delete server | + +**Features:** +- All endpoints require authentication +- API tokens encrypted before storage +- Automatic local path generation based on server name +- SSH key validation +- Partial update support (only provided fields are updated) +- Name uniqueness validation +- Standard response format using `SuccessResponse` wrapper + +**Update Support:** +- All fields optional for updates +- API token re-encryption on update +- Local path regeneration when name changes +- Timestamp auto-update + +**Error Handling:** +- 400: Validation errors (duplicate name, invalid SSH key, no fields provided) +- 401: Authentication failures +- 404: Server not found + +--- + +### Task 5.4: Status API (`app/api/status.py`) + +**File:** `C:\electron\git\backend\app\api\status.py` + +**Implemented Endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/status` | Get system status | +| GET | `/api/status/health` | Health check endpoint | + +**Features:** +- Optional authentication (works for both authenticated and anonymous users) +- Database connectivity check +- Resource counts (servers, SSH keys, repos) +- Storage path information (authenticated users only) +- Application version information +- Health status indicator + +**Response Data:** +- Status: "healthy", "degraded", or "error" +- Version: Application version string +- Database: Connection status and counts +- Storage: Path information (authenticated only) +- Authenticated: Boolean indicating auth status + +--- + +## Phase 6: FastAPI Main Application + +### Task 6.1: Main Application (`app/main.py`) + +**File:** `C:\electron\git\backend\app\main.py` + +**Implemented Features:** + +1. **Lifespan Management** + - Database initialization on startup + - Table creation using SQLAlchemy Base metadata + - Required directory creation (data_dir, ssh_keys_dir, repos_dir) + - Database connection cleanup on shutdown + +2. **Router Registration** + - SSH Keys router (`/api/ssh-keys`) + - Servers router (`/api/servers`) + - Status router (`/api/status`) + +3. **CORS Middleware** + - Configured for all origins (adjustable for production) + - Credentials support enabled + - All methods and headers allowed + +4. **Exception Handlers** + - SQLAlchemyError: Database errors → 500 + - ValueError: Validation errors → 400 + - Generic Exception: Internal server errors → 500 + +5. **API Documentation** + - Swagger UI: `/api/docs` + - ReDoc: `/api/redoc` + - OpenAPI schema: `/api/openapi.json` + +6. **Static File Serving** + - Frontend build files served from `frontend/dist` + - Mounted at root path `/` + - Only enabled if frontend build exists + +7. **Configuration** + - Application title: "Git Manager API" + - Version: 1.0.0 + - Description included + +--- + +## Test Files + +### Test Configuration Update + +**File:** `C:\electron\git\backend\tests\conftest.py` + +**Added Fixture:** +- **`client`**: FastAPI TestClient fixture + - In-memory database session + - Test environment variables + - Lifespan disabled for faster tests + - Dependency override for `get_db_session` + - Proper cleanup between tests + +### Test Suites + +#### 1. SSH Keys API Tests (`tests/test_api/test_ssh_keys_api.py`) + +**Test Classes:** +- `TestCreateSshKey` (7 tests) +- `TestListSshKeys` (3 tests) +- `TestGetSshKey` (3 tests) +- `TestDeleteSshKey` (4 tests) + +**Coverage:** +- Success cases for all operations +- Validation errors (duplicate name, invalid key format, empty fields) +- Authentication (missing token, invalid token) +- Authorization (protected endpoints) +- Business logic (key in use prevention) + +#### 2. Servers API Tests (`tests/test_api/test_servers_api.py`) + +**Test Classes:** +- `TestCreateServer` (6 tests) +- `TestListServers` (3 tests) +- `TestGetServer` (3 tests) +- `TestUpdateServer` (7 tests) +- `TestDeleteServer` (3 tests) + +**Coverage:** +- Success cases for all operations +- Validation errors (duplicate name, invalid SSH key) +- Update operations (single and multiple fields) +- Authentication/authorization +- API token updates +- Edge cases (no fields provided) + +#### 3. Status API Tests (`tests/test_api/test_status_api.py`) + +**Test Classes:** +- `TestGetStatus` (4 tests) +- `TestHealthCheck` (3 tests) + +**Coverage:** +- Authenticated vs anonymous responses +- Database counts accuracy +- Storage path inclusion based on auth +- Health check accessibility + +--- + +## API Response Format + +### Success Response + +```json +{ + "code": 0, + "data": { ... }, + "message": "success message" +} +``` + +### Error Response + +```json +{ + "code": 400, + "message": "error message", + "data": null +} +``` + +--- + +## Authentication + +All API endpoints (except status health check) require Bearer token authentication: + +``` +Authorization: Bearer +``` + +Token is validated against the `GM_API_TOKEN` environment variable. + +--- + +## Directory Structure + +``` +backend/ +├── app/ +│ ├── api/ +│ │ ├── __init__.py # API module exports +│ │ ├── deps.py # Dependencies (DB session, auth) +│ │ ├── ssh_keys.py # SSH keys endpoints +│ │ ├── servers.py # Servers endpoints +│ │ └── status.py # Status endpoints +│ └── main.py # FastAPI application +└── tests/ + ├── conftest.py # Test fixtures (updated) + └── test_api/ + ├── __init__.py + ├── test_ssh_keys_api.py # SSH keys tests + ├── test_servers_api.py # Servers tests + └── test_status_api.py # Status tests +``` + +--- + +## Usage + +### Running the Application + +```bash +# Set environment variables +export GM_ENCRYPT_KEY=$(base64 <<< "your-32-byte-encryption-key") +export GM_API_TOKEN="your-api-token" +export GM_DATA_DIR="/path/to/data" + +# Run with uvicorn +cd backend +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### Running Tests + +```bash +cd backend +python -m pytest tests/test_api/ -v +``` + +--- + +## Dependencies + +The implementation relies on existing Phase 1-4 components: +- **Models**: `SshKey`, `Server`, `Repo`, `SyncLog` +- **Schemas**: `SshKeyCreate`, `SshKeyResponse`, `ServerCreate`, `ServerUpdate`, `ServerResponse`, `SuccessResponse`, `ErrorResponse` +- **Services**: `SshKeyService`, `ServerService` +- **Security**: `encrypt_data`, `decrypt_data`, `verify_api_token` +- **Database**: `init_db`, `get_engine`, `get_session_factory` +- **Config**: `get_settings`, `Settings` + +--- + +## API Endpoints Summary + +| Path | Methods | Auth Required | Description | +|------|---------|---------------|-------------| +| `/api/ssh-keys` | GET, POST | Yes | List and create SSH keys | +| `/api/ssh-keys/{id}` | GET, DELETE | Yes | Get and delete SSH keys | +| `/api/servers` | GET, POST | Yes | List and create servers | +| `/api/servers/{id}` | GET, PUT, DELETE | Yes | Get, update, delete servers | +| `/api/status` | GET | No | Get system status | +| `/api/status/health` | GET | No | Health check | +| `/api/docs` | GET | No | Swagger UI documentation | +| `/api/redoc` | GET | No | ReDoc documentation | +| `/` | GET | No | Static files (if frontend built) | + +--- + +## Implementation Status + +✅ **Phase 5: API Routes - COMPLETE** +- ✅ Task 5.1: API Dependencies (deps.py) +- ✅ Task 5.2: SSH Keys API +- ✅ Task 5.3: Servers API +- ✅ Task 5.4: Status API + +✅ **Phase 6: FastAPI Main Application - COMPLETE** +- ✅ Task 6.1: Main application with routes, lifespan, static files +- ✅ Task 6.1: Updated conftest.py for test client + +--- + +## Files Created + +1. `backend/app/api/__init__.py` - API module exports +2. `backend/app/api/deps.py` - Dependency injection +3. `backend/app/api/ssh_keys.py` - SSH keys CRUD endpoints +4. `backend/app/api/servers.py` - Servers CRUD endpoints +5. `backend/app/api/status.py` - System status endpoint +6. `backend/app/main.py` - FastAPI main application +7. `backend/tests/test_api/__init__.py` - Test package init +8. `backend/tests/test_api/test_ssh_keys_api.py` - SSH keys tests (17 tests) +9. `backend/tests/test_api/test_servers_api.py` - Servers tests (22 tests) +10. `backend/tests/test_api/test_status_api.py` - Status tests (7 tests) +11. `backend/tests/conftest.py` - Updated with client fixture + +**Total: 11 files created/updated** +**Total: 46 test cases implemented** + +--- + +## Next Steps + +The following phases remain to be implemented: +- **Phase 7**: Frontend (Vue 3 + TypeScript) +- **Phase 8**: Documentation and deployment scripts + +--- + +## Notes + +1. All sensitive data (SSH private keys, API tokens) are encrypted before storage +2. Standard HTTP status codes are used (200, 201, 400, 401, 404, 500) +3. Consistent response format across all endpoints +4. Database sessions are properly managed with automatic cleanup +5. CORS is configured for development (restrict in production) +6. Static file serving is optional (only if frontend build exists) +7. All endpoints return JSON responses +8. Authentication uses Bearer token scheme +9. Error messages are descriptive and helpful +10. Tests cover success cases, validation errors, and authentication/authorization diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e69de29..4513560 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -0,0 +1,17 @@ +""" +API routes module. + +This package contains all FastAPI route handlers. +""" +from app.api.deps import get_db_session, require_auth +from app.api.ssh_keys import router as ssh_keys_router +from app.api.servers import router as servers_router +from app.api.status import router as status_router + +__all__ = [ + "get_db_session", + "require_auth", + "ssh_keys_router", + "servers_router", + "status_router", +] diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..8c589cb --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,111 @@ +""" +FastAPI dependencies for API routes. + +Provides reusable dependencies for: +- Database session management +- Authentication/authorization +""" +from typing import Generator, Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +from app.database import get_session_factory +from app.security import verify_api_token + + +# HTTP Bearer token security scheme +security = HTTPBearer(auto_error=False) + + +def get_db_session() -> Generator[Session, None, None]: + """ + Dependency to get a database session. + + Yields: + SQLAlchemy database session + + Example: + @app.get("/items") + def read_items(db: Session = Depends(get_db_session)): + items = db.query(Item).all() + return items + """ + session_factory = get_session_factory() + if session_factory is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database not initialized" + ) + + session = session_factory() + try: + yield session + finally: + session.close() + + +def require_auth( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> None: + """ + Dependency to require authentication for protected endpoints. + + Args: + credentials: HTTP Bearer token credentials + + Raises: + HTTPException: If authentication fails (401 Unauthorized) + + Example: + @app.get("/protected") + def protected_route(auth: None = Depends(require_auth)): + return {"message": "authenticated"} + """ + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + authorization = f"Bearer {credentials.credentials}" + if not verify_api_token(authorization): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Return None to indicate successful authentication + return None + + +async def require_auth_optional( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> bool: + """ + Optional authentication dependency. + Returns True if authenticated, False otherwise. + + This is useful for endpoints that have different behavior + based on authentication status but don't require it. + + Args: + credentials: HTTP Bearer token credentials + + Returns: + bool: True if authenticated, False otherwise + + Example: + @app.get("/public") + def public_route(authenticated: bool = Depends(require_auth_optional)): + if authenticated: + return {"message": "authenticated user"} + return {"message": "anonymous user"} + """ + if credentials is None: + return False + + authorization = f"Bearer {credentials.credentials}" + return verify_api_token(authorization) diff --git a/backend/app/api/servers.py b/backend/app/api/servers.py new file mode 100644 index 0000000..723daaa --- /dev/null +++ b/backend/app/api/servers.py @@ -0,0 +1,292 @@ +""" +Servers API routes. + +Provides CRUD endpoints for Gitea server management: +- POST /api/servers - Create a new server +- GET /api/servers - List all servers +- GET /api/servers/{id} - Get a specific server +- PUT /api/servers/{id} - Update a server +- DELETE /api/servers/{id} - Delete a server +""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db_session, require_auth +from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse +from app.schemas.common import SuccessResponse +from app.services.server_service import ServerService + + +router = APIRouter(prefix="/api/servers", tags=["Servers"]) + + +@router.post("", response_model=SuccessResponse[ServerResponse], status_code=status.HTTP_201_CREATED) +def create_server( + server_data: ServerCreate, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Create a new Gitea server. + + The API token will be encrypted before storage. + The name must be unique across all servers. + A local storage path will be automatically generated based on the server name. + + Args: + server_data: Server creation data + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing the created server + + Raises: + HTTPException 400: If validation fails or name already exists + HTTPException 401: If authentication fails + """ + service = ServerService(db) + + try: + server = service.create_server( + name=server_data.name, + url=server_data.url, + api_token=server_data.api_token, + ssh_key_id=server_data.ssh_key_id, + sync_enabled=server_data.sync_enabled, + schedule_cron=server_data.schedule_cron + ) + + return SuccessResponse( + code=0, + data=ServerResponse( + id=server.id, + name=server.name, + url=server.url, + ssh_key_id=server.ssh_key_id, + sync_enabled=server.sync_enabled, + schedule_cron=server.schedule_cron, + local_path=server.local_path, + status=server.status, + created_at=server.created_at, + updated_at=server.updated_at + ), + message="Server created successfully" + ) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.get("", response_model=SuccessResponse[List[ServerResponse]]) +def list_servers( + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + List all servers. + + Returns all servers ordered by creation time. + API tokens are not included in the response. + + Args: + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing list of servers + + Raises: + HTTPException 401: If authentication fails + """ + service = ServerService(db) + servers = service.list_servers() + + return SuccessResponse( + code=0, + data=[ + ServerResponse( + id=server.id, + name=server.name, + url=server.url, + ssh_key_id=server.ssh_key_id, + sync_enabled=server.sync_enabled, + schedule_cron=server.schedule_cron, + local_path=server.local_path, + status=server.status, + created_at=server.created_at, + updated_at=server.updated_at + ) + for server in servers + ], + message=f"Retrieved {len(servers)} server(s)" + ) + + +@router.get("/{server_id}", response_model=SuccessResponse[ServerResponse]) +def get_server( + server_id: int, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Get a specific server by ID. + + Args: + server_id: ID of the server + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing the server + + Raises: + HTTPException 401: If authentication fails + HTTPException 404: If server not found + """ + service = ServerService(db) + server = service.get_server(server_id) + + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Server with ID {server_id} not found" + ) + + return SuccessResponse( + code=0, + data=ServerResponse( + id=server.id, + name=server.name, + url=server.url, + ssh_key_id=server.ssh_key_id, + sync_enabled=server.sync_enabled, + schedule_cron=server.schedule_cron, + local_path=server.local_path, + status=server.status, + created_at=server.created_at, + updated_at=server.updated_at + ), + message="Server retrieved successfully" + ) + + +@router.put("/{server_id}", response_model=SuccessResponse[ServerResponse]) +def update_server( + server_id: int, + server_data: ServerUpdate, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Update a server. + + Only the fields provided in the request body will be updated. + If api_token is provided, it will be encrypted before storage. + If name is changed, the local_path will be updated accordingly. + + Args: + server_id: ID of the server to update + server_data: Server update data (all fields optional) + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing the updated server + + Raises: + HTTPException 400: If validation fails + HTTPException 401: If authentication fails + HTTPException 404: If server not found + """ + service = ServerService(db) + + # Build update dict from non-None fields + update_data = {} + if server_data.name is not None: + update_data['name'] = server_data.name + if server_data.url is not None: + update_data['url'] = server_data.url + if server_data.api_token is not None: + update_data['api_token'] = server_data.api_token + if server_data.ssh_key_id is not None: + update_data['ssh_key_id'] = server_data.ssh_key_id + if server_data.sync_enabled is not None: + update_data['sync_enabled'] = server_data.sync_enabled + if server_data.schedule_cron is not None: + update_data['schedule_cron'] = server_data.schedule_cron + if server_data.status is not None: + update_data['status'] = server_data.status + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No fields provided for update" + ) + + try: + server = service.update_server(server_id, **update_data) + + return SuccessResponse( + code=0, + data=ServerResponse( + id=server.id, + name=server.name, + url=server.url, + ssh_key_id=server.ssh_key_id, + sync_enabled=server.sync_enabled, + schedule_cron=server.schedule_cron, + local_path=server.local_path, + status=server.status, + created_at=server.created_at, + updated_at=server.updated_at + ), + message="Server updated successfully" + ) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.delete("/{server_id}", response_model=SuccessResponse[dict]) +def delete_server( + server_id: int, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Delete a server. + + Args: + server_id: ID of the server to delete + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse with empty data + + Raises: + HTTPException 401: If authentication fails + HTTPException 404: If server not found + """ + service = ServerService(db) + deleted = service.delete_server(server_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Server with ID {server_id} not found" + ) + + return SuccessResponse( + code=0, + data={}, + message="Server deleted successfully" + ) diff --git a/backend/app/api/ssh_keys.py b/backend/app/api/ssh_keys.py new file mode 100644 index 0000000..ea11de8 --- /dev/null +++ b/backend/app/api/ssh_keys.py @@ -0,0 +1,201 @@ +""" +SSH Keys API routes. + +Provides CRUD endpoints for SSH key management: +- POST /api/ssh-keys - Create a new SSH key +- GET /api/ssh-keys - List all SSH keys +- GET /api/ssh-keys/{id} - Get a specific SSH key +- DELETE /api/ssh-keys/{id} - Delete an SSH key +""" +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db_session, require_auth +from app.schemas.ssh_key import SshKeyCreate, SshKeyResponse +from app.schemas.common import SuccessResponse, ErrorResponse +from app.services.ssh_key_service import SshKeyService + + +router = APIRouter(prefix="/api/ssh-keys", tags=["SSH Keys"]) + + +@router.post("", response_model=SuccessResponse[SshKeyResponse], status_code=status.HTTP_201_CREATED) +def create_ssh_key( + ssh_key_data: SshKeyCreate, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Create a new SSH key. + + The private key will be encrypted before storage. + The name must be unique across all SSH keys. + + Args: + ssh_key_data: SSH key creation data (name, private_key) + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing the created SSH key + + Raises: + HTTPException 400: If validation fails or name already exists + HTTPException 401: If authentication fails + """ + service = SshKeyService(db) + + try: + ssh_key = service.create_ssh_key( + name=ssh_key_data.name, + private_key=ssh_key_data.private_key + ) + + return SuccessResponse( + code=0, + data=SshKeyResponse( + id=ssh_key.id, + name=ssh_key.name, + fingerprint=ssh_key.fingerprint, + created_at=ssh_key.created_at + ), + message="SSH key created successfully" + ) + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.get("", response_model=SuccessResponse[List[SshKeyResponse]]) +def list_ssh_keys( + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + List all SSH keys. + + Returns all SSH keys ordered by creation time. + Private keys are not included in the response. + + Args: + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing list of SSH keys + + Raises: + HTTPException 401: If authentication fails + """ + service = SshKeyService(db) + ssh_keys = service.list_ssh_keys() + + return SuccessResponse( + code=0, + data=[ + SshKeyResponse( + id=key.id, + name=key.name, + fingerprint=key.fingerprint, + created_at=key.created_at + ) + for key in ssh_keys + ], + message=f"Retrieved {len(ssh_keys)} SSH key(s)" + ) + + +@router.get("/{key_id}", response_model=SuccessResponse[SshKeyResponse]) +def get_ssh_key( + key_id: int, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Get a specific SSH key by ID. + + Args: + key_id: ID of the SSH key + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse containing the SSH key + + Raises: + HTTPException 401: If authentication fails + HTTPException 404: If SSH key not found + """ + service = SshKeyService(db) + ssh_key = service.get_ssh_key(key_id) + + if ssh_key is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"SSH key with ID {key_id} not found" + ) + + return SuccessResponse( + code=0, + data=SshKeyResponse( + id=ssh_key.id, + name=ssh_key.name, + fingerprint=ssh_key.fingerprint, + created_at=ssh_key.created_at + ), + message="SSH key retrieved successfully" + ) + + +@router.delete("/{key_id}", response_model=SuccessResponse[dict]) +def delete_ssh_key( + key_id: int, + db: Session = Depends(get_db_session), + _auth: None = Depends(require_auth) +): + """ + Delete an SSH key. + + The SSH key can only be deleted if it is not in use by any server. + If servers are using this key, the deletion will fail. + + Args: + key_id: ID of the SSH key to delete + db: Database session (injected) + _auth: Authentication requirement (injected) + + Returns: + SuccessResponse with empty data + + Raises: + HTTPException 400: If key is in use by servers + HTTPException 401: If authentication fails + HTTPException 404: If SSH key not found + """ + service = SshKeyService(db) + + try: + deleted = service.delete_ssh_key(key_id) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"SSH key with ID {key_id} not found" + ) + + return SuccessResponse( + code=0, + data={}, + message="SSH key deleted successfully" + ) + + except ValueError as e: + # Key is in use by servers + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) diff --git a/backend/app/api/status.py b/backend/app/api/status.py new file mode 100644 index 0000000..b6a1875 --- /dev/null +++ b/backend/app/api/status.py @@ -0,0 +1,138 @@ +""" +Status API routes. + +Provides system status and health check endpoints: +- GET /api/status - Get system status and health information +""" +from typing import Dict, Any +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import text + +from app.api.deps import get_db_session, require_auth_optional +from app.schemas.common import SuccessResponse +from app.config import get_settings +from app.models.server import Server +from app.models.ssh_key import SshKey +from app.models.repo import Repo + + +router = APIRouter(prefix="/api/status", tags=["Status"]) + + +@router.get("", response_model=SuccessResponse[Dict[str, Any]]) +def get_status( + db: Session = Depends(get_db_session), + _authenticated: bool = Depends(require_auth_optional) +): + """ + Get system status and health information. + + This endpoint provides information about: + - Application status and version + - Database connectivity and statistics + - Counts of servers, SSH keys, and repositories + - Storage paths + + Authentication is optional for this endpoint. + Authenticated users may receive additional information. + + Args: + db: Database session (injected) + _authenticated: Whether request is authenticated (injected) + + Returns: + SuccessResponse containing system status information + + Example response: + { + "code": 0, + "data": { + "status": "healthy", + "version": "1.0.0", + "database": { + "status": "connected", + "servers_count": 2, + "ssh_keys_count": 3, + "repos_count": 15 + }, + "storage": { + "data_dir": "/path/to/data", + "repos_dir": "/path/to/data/repos", + "ssh_keys_dir": "/path/to/data/ssh_keys" + }, + "authenticated": true + }, + "message": "System status retrieved successfully" + } + """ + settings = get_settings() + status_info: Dict[str, Any] = { + "status": "healthy", + "version": "1.0.0", + "authenticated": _authenticated + } + + # Check database connectivity + try: + # Execute a simple query to verify database connection + db.execute(text("SELECT 1")) + + # Get counts for each model + servers_count = db.query(Server).count() + ssh_keys_count = db.query(SshKey).count() + repos_count = db.query(Repo).count() + + status_info["database"] = { + "status": "connected", + "servers_count": servers_count, + "ssh_keys_count": ssh_keys_count, + "repos_count": repos_count + } + + except Exception as e: + status_info["database"] = { + "status": "error", + "error": str(e) + } + status_info["status"] = "degraded" + + # Storage paths (only show to authenticated users) + if _authenticated: + status_info["storage"] = { + "data_dir": str(settings.data_dir), + "repos_dir": str(settings.repos_dir), + "ssh_keys_dir": str(settings.ssh_keys_dir), + "db_path": str(settings.db_path) + } + + return SuccessResponse( + code=0, + data=status_info, + message="System status retrieved successfully" + ) + + +@router.get("/health", response_model=SuccessResponse[Dict[str, str]]) +def health_check(): + """ + Simple health check endpoint. + + This is a lightweight endpoint for load balancers and monitoring systems. + It always returns 200 OK when the service is running. + + Returns: + SuccessResponse indicating healthy status + + Example response: + { + "code": 0, + "data": {"status": "ok"}, + "message": "Service is healthy" + } + """ + return SuccessResponse( + code=0, + data={"status": "ok"}, + message="Service is healthy" + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d2be078 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,183 @@ +""" +FastAPI main application. + +This module creates and configures the FastAPI application with: +- All API routers registered +- Lifespan events for database initialization +- Static file serving for the frontend +- CORS middleware +- Exception handlers +""" +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Callable + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.exc import SQLAlchemyError + +from app.config import get_settings +from app.database import init_db, get_engine +from app.models import Base # noqa: F401 - Import to ensure models are registered + + +# Import API routers +from app.api.ssh_keys import router as ssh_keys_router +from app.api.servers import router as servers_router +from app.api.status import router as status_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): # noqa: ARG001 - Unused app parameter + """ + Lifespan context manager for FastAPI application. + + Handles startup and shutdown events: + - Startup: Initialize database and create tables + - Shutdown: Close database connections + + Yields: + None + """ + # Startup + settings = get_settings() + + # Initialize database + init_db(settings.db_path) + + # Create all tables + engine = get_engine() + if engine is not None: + Base.metadata.create_all(engine) + + # Ensure required directories exist + settings.data_dir.mkdir(parents=True, exist_ok=True) + settings.ssh_keys_dir.mkdir(parents=True, exist_ok=True) + settings.repos_dir.mkdir(parents=True, exist_ok=True) + + yield + + # Shutdown + # Close database connections + if engine is not None: + engine.dispose() + + +def create_app(lifespan_handler: Callable = lifespan) -> FastAPI: + """ + Create and configure the FastAPI application. + + Args: + lifespan_handler: Lifespan context manager (for testing) + + Returns: + Configured FastAPI application instance + """ + settings = get_settings() + + app = FastAPI( + title="Git Manager API", + description="API for managing Gitea server mirrors and SSH keys", + version="1.0.0", + lifespan=lifespan_handler, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json" + ) + + # Configure CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify exact origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Register exception handlers + register_exception_handlers(app) + + # Register API routers + app.include_router(ssh_keys_router) + app.include_router(servers_router) + app.include_router(status_router) + + # Mount static files for frontend + # Check if frontend build exists + frontend_path = Path(__file__).parent.parent.parent / "frontend" / "dist" + if frontend_path.exists(): + app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="frontend") + + return app + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register global exception handlers for the application. + + Args: + app: FastAPI application instance + """ + + @app.exception_handler(SQLAlchemyError) + async def sqlalchemy_error_handler( + request: Request, # noqa: ARG001 - Unused request parameter + exc: SQLAlchemyError + ): + """Handle SQLAlchemy database errors.""" + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "code": 500, + "message": "Database error occurred", + "data": {"detail": str(exc)} + } + ) + + @app.exception_handler(ValueError) + async def value_error_handler( + request: Request, # noqa: ARG001 - Unused request parameter + exc: ValueError + ): + """Handle ValueError exceptions.""" + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={ + "code": 400, + "message": str(exc), + "data": None + } + ) + + @app.exception_handler(Exception) + async def general_exception_handler( + request: Request, # noqa: ARG001 - Unused request parameter + exc: Exception + ): + """Handle all other exceptions.""" + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "code": 500, + "message": "Internal server error", + "data": {"detail": str(exc)} + } + ) + + +# Create the application instance +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + settings = get_settings() + uvicorn.run( + "app.main:app", + host=settings.host, + port=settings.port, + reload=True + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e69de29..65aef18 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,38 @@ +""" +Pydantic schemas for API request/response validation. + +This module exports all schemas used throughout the application. +""" + +# Common schemas +from app.schemas.common import SuccessResponse, ErrorResponse + +# SSH Key schemas +from app.schemas.ssh_key import SshKeyCreate, SshKeyResponse + +# Server schemas +from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse + +# Repository schemas +from app.schemas.repo import RepoResponse, CommitInfo + +# Sync Log schemas +from app.schemas.sync_log import SyncLogResponse + +__all__ = [ + # Common + "SuccessResponse", + "ErrorResponse", + # SSH Key + "SshKeyCreate", + "SshKeyResponse", + # Server + "ServerCreate", + "ServerUpdate", + "ServerResponse", + # Repository + "RepoResponse", + "CommitInfo", + # Sync Log + "SyncLogResponse", +] diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..ff4cdf5 --- /dev/null +++ b/backend/app/schemas/common.py @@ -0,0 +1,55 @@ +""" +Common Pydantic schemas for API responses. +""" +from typing import Generic, TypeVar, Optional +from pydantic import BaseModel, Field + + +T = TypeVar("T") + + +class SuccessResponse(BaseModel, Generic[T]): + """ + Standard success response wrapper. + """ + code: int = Field(default=0, description="Response code, 0 for success") + data: T = Field(description="Response data") + message: str = Field(default="success", description="Response message") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "code": 0, + "data": {}, + "message": "success" + } + ] + } + } + + +class ErrorResponse(BaseModel): + """ + Standard error response wrapper. + """ + code: int = Field(description="Error code, non-zero for errors") + message: str = Field(description="Error message") + data: Optional[dict] = Field(default=None, description="Additional error data") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "code": 400, + "message": "Bad request", + "data": None + }, + { + "code": 404, + "message": "Resource not found", + "data": {"detail": "Item with id 123 not found"} + } + ] + } + } diff --git a/backend/app/schemas/repo.py b/backend/app/schemas/repo.py new file mode 100644 index 0000000..8440aa1 --- /dev/null +++ b/backend/app/schemas/repo.py @@ -0,0 +1,64 @@ +""" +Repository Pydantic schemas. +""" +from typing import Optional +from pydantic import BaseModel, Field + + +class CommitInfo(BaseModel): + """ + Schema for commit information. + """ + hash: str = Field(description="Commit hash") + author: str = Field(description="Commit author") + message: str = Field(description="Commit message") + timestamp: int = Field(description="Commit timestamp (Unix timestamp)") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "hash": "a1b2c3d4e5f6...", + "author": "John Doe ", + "message": "Add new feature", + "timestamp": 1711891200 + } + ] + } + } + + +class RepoResponse(BaseModel): + """ + Schema for repository response. + """ + id: int = Field(description="Repository ID") + server_id: int = Field(description="Server ID") + name: str = Field(description="Repository name") + full_name: str = Field(description="Repository full name (e.g., 'owner/repo')") + clone_url: str = Field(description="Git clone URL") + local_path: str = Field(description="Local storage path") + last_sync_at: Optional[int] = Field( + default=None, + description="Last sync timestamp (Unix timestamp)" + ) + status: str = Field(description="Repository status") + created_at: int = Field(description="Creation timestamp (Unix timestamp)") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "id": 1, + "server_id": 1, + "name": "my-repo", + "full_name": "myorg/my-repo", + "clone_url": "https://gitea.example.com/myorg/my-repo.git", + "local_path": "/data/gitea-mirror/myorg/my-repo", + "last_sync_at": 1711891200, + "status": "success", + "created_at": 1711804800 + } + ] + } + } diff --git a/backend/app/schemas/server.py b/backend/app/schemas/server.py new file mode 100644 index 0000000..f8387a4 --- /dev/null +++ b/backend/app/schemas/server.py @@ -0,0 +1,172 @@ +""" +Server Pydantic schemas. +""" +from typing import Optional +from pydantic import BaseModel, Field, field_validator + + +class ServerCreate(BaseModel): + """ + Schema for creating a new server. + """ + name: str = Field(..., min_length=1, max_length=100, description="Server name") + url: str = Field(..., min_length=1, max_length=500, description="Gitea server URL") + api_token: str = Field(..., min_length=1, description="Gitea API token") + ssh_key_id: int = Field(..., gt=0, description="SSH key ID to use") + local_path: str = Field(..., min_length=1, max_length=500, description="Local storage path") + sync_enabled: bool = Field(default=False, description="Whether sync is enabled") + schedule_cron: Optional[str] = Field( + default=None, + max_length=50, + description="Cron expression for scheduled sync" + ) + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, v: str) -> str: + """Validate that name is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("name must not be empty") + return v.strip() + + @field_validator("url") + @classmethod + def url_must_not_be_empty(cls, v: str) -> str: + """Validate that url is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("url must not be empty") + return v.strip() + + @field_validator("api_token") + @classmethod + def api_token_must_not_be_empty(cls, v: str) -> str: + """Validate that api_token is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("api_token must not be empty") + return v.strip() + + @field_validator("local_path") + @classmethod + def local_path_must_not_be_empty(cls, v: str) -> str: + """Validate that local_path is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("local_path must not be empty") + return v.strip() + + model_config = { + "json_schema_extra": { + "examples": [ + { + "name": "my-gitea", + "url": "https://gitea.example.com", + "api_token": "your_api_token_here", + "ssh_key_id": 1, + "local_path": "/data/gitea-mirror", + "sync_enabled": False, + "schedule_cron": None + } + ] + } + } + + +class ServerUpdate(BaseModel): + """ + Schema for updating a server. + All fields are optional. + """ + name: Optional[str] = Field(None, min_length=1, max_length=100, description="Server name") + url: Optional[str] = Field(None, min_length=1, max_length=500, description="Gitea server URL") + api_token: Optional[str] = Field(None, min_length=1, description="Gitea API token") + ssh_key_id: Optional[int] = Field(None, gt=0, description="SSH key ID to use") + local_path: Optional[str] = Field(None, min_length=1, max_length=500, description="Local storage path") + sync_enabled: Optional[bool] = Field(None, description="Whether sync is enabled") + schedule_cron: Optional[str] = Field( + None, + max_length=50, + description="Cron expression for scheduled sync" + ) + status: Optional[str] = Field( + None, + pattern="^(untested|testing|success|error)$", + description="Server status" + ) + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]: + """Validate that name is not empty or whitespace only.""" + if v is not None and (not v or not v.strip()): + raise ValueError("name must not be empty") + return v.strip() if v else None + + @field_validator("url") + @classmethod + def url_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]: + """Validate that url is not empty or whitespace only.""" + if v is not None and (not v or not v.strip()): + raise ValueError("url must not be empty") + return v.strip() if v else None + + @field_validator("api_token") + @classmethod + def api_token_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]: + """Validate that api_token is not empty or whitespace only.""" + if v is not None and (not v or not v.strip()): + raise ValueError("api_token must not be empty") + return v.strip() if v else None + + @field_validator("local_path") + @classmethod + def local_path_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]: + """Validate that local_path is not empty or whitespace only.""" + if v is not None and (not v or not v.strip()): + raise ValueError("local_path must not be empty") + return v.strip() if v else None + + model_config = { + "json_schema_extra": { + "examples": [ + { + "name": "updated-gitea", + "sync_enabled": True, + "schedule_cron": "0 */6 * * *" + } + ] + } + } + + +class ServerResponse(BaseModel): + """ + Schema for server response. + """ + id: int = Field(description="Server ID") + name: str = Field(description="Server name") + url: str = Field(description="Gitea server URL") + ssh_key_id: int = Field(description="SSH key ID") + sync_enabled: bool = Field(description="Whether sync is enabled") + schedule_cron: Optional[str] = Field(default=None, description="Cron expression") + local_path: str = Field(description="Local storage path") + status: str = Field(description="Server status") + created_at: int = Field(description="Creation timestamp (Unix timestamp)") + updated_at: int = Field(description="Last update timestamp (Unix timestamp)") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "id": 1, + "name": "my-gitea", + "url": "https://gitea.example.com", + "ssh_key_id": 1, + "sync_enabled": True, + "schedule_cron": "0 */6 * * *", + "local_path": "/data/gitea-mirror", + "status": "success", + "created_at": 1711804800, + "updated_at": 1711891200 + } + ] + } + } diff --git a/backend/app/schemas/ssh_key.py b/backend/app/schemas/ssh_key.py new file mode 100644 index 0000000..699188e --- /dev/null +++ b/backend/app/schemas/ssh_key.py @@ -0,0 +1,63 @@ +""" +SSH Key Pydantic schemas. +""" +from typing import Optional +from pydantic import BaseModel, Field, field_validator + + +class SshKeyCreate(BaseModel): + """ + Schema for creating a new SSH key. + """ + name: str = Field(..., min_length=1, max_length=100, description="SSH key name") + private_key: str = Field(..., min_length=1, description="SSH private key content") + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, v: str) -> str: + """Validate that name is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("name must not be empty") + return v.strip() + + @field_validator("private_key") + @classmethod + def private_key_must_not_be_empty(cls, v: str) -> str: + """Validate that private_key is not empty or whitespace only.""" + if not v or not v.strip(): + raise ValueError("private_key must not be empty") + return v.strip() + + model_config = { + "json_schema_extra": { + "examples": [ + { + "name": "my-git-key", + "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----" + } + ] + } + } + + +class SshKeyResponse(BaseModel): + """ + Schema for SSH key response. + """ + id: int = Field(description="SSH key ID") + name: str = Field(description="SSH key name") + fingerprint: Optional[str] = Field(default=None, description="SSH key fingerprint") + created_at: int = Field(description="Creation timestamp (Unix timestamp)") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "id": 1, + "name": "my-git-key", + "fingerprint": "SHA256:abc123...", + "created_at": 1711804800 + } + ] + } + } diff --git a/backend/app/schemas/sync_log.py b/backend/app/schemas/sync_log.py new file mode 100644 index 0000000..0501c50 --- /dev/null +++ b/backend/app/schemas/sync_log.py @@ -0,0 +1,52 @@ +""" +Sync Log Pydantic schemas. +""" +from typing import Optional +from pydantic import BaseModel, Field + + +class SyncLogResponse(BaseModel): + """ + Schema for sync log response. + """ + id: int = Field(description="Sync log ID") + repo_id: int = Field(description="Repository ID") + status: str = Field(description="Sync status") + started_at: int = Field(description="Sync start timestamp (Unix timestamp)") + finished_at: int = Field(description="Sync finish timestamp (Unix timestamp)") + commits_count: Optional[int] = Field( + default=None, + description="Number of commits synced" + ) + error_msg: Optional[str] = Field( + default=None, + description="Error message if sync failed" + ) + created_at: int = Field(description="Creation timestamp (Unix timestamp)") + + model_config = { + "json_schema_extra": { + "examples": [ + { + "id": 1, + "repo_id": 1, + "status": "success", + "started_at": 1711891200, + "finished_at": 1711891500, + "commits_count": 5, + "error_msg": None, + "created_at": 1711891200 + }, + { + "id": 2, + "repo_id": 1, + "status": "error", + "started_at": 1711891800, + "finished_at": 1711892000, + "commits_count": None, + "error_msg": "Connection timeout", + "created_at": 1711891800 + } + ] + } + } diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 1261f5d..bac8d12 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -5,5 +5,7 @@ Business logic layer for application services. """ from app.services.ssh_key_service import SshKeyService from app.services.server_service import ServerService +from app.services.sync_service import SyncService +from app.services.repo_service import RepoService -__all__ = ['SshKeyService', 'ServerService'] +__all__ = ['SshKeyService', 'ServerService', 'SyncService', 'RepoService'] diff --git a/backend/app/services/repo_service.py b/backend/app/services/repo_service.py new file mode 100644 index 0000000..d0447f7 --- /dev/null +++ b/backend/app/services/repo_service.py @@ -0,0 +1,227 @@ +""" +Repo Service. + +Business logic for repository management including: +- Creating repository records +- Listing repositories by server +- Retrieving repository details +- Updating repository status +- Deleting repository records +""" +import time +from typing import List, Optional +from pathlib import Path +from sqlalchemy.orm import Session + +from app.models.repo import Repo +from app.models.server import Server +from app.config import get_settings + + +class RepoService: + """ + Service for managing repository records. + + Handles CRUD operations for repositories that are being + mirrored from Gitea servers. + """ + + def __init__(self, db: Session): + """ + Initialize the service with a database session. + + Args: + db: SQLAlchemy database session + """ + self.db = db + self.settings = get_settings() + + def create_repo( + self, + server_id: int, + name: str, + full_name: str, + clone_url: str, + local_path: Optional[str] = None + ) -> Repo: + """ + Create a new repository record. + + Args: + server_id: ID of the server this repo belongs to + name: Repository name (e.g., "my-repo") + full_name: Full repository name (e.g., "owner/my-repo") + clone_url: Git clone URL (typically SSH format) + local_path: Optional local path for the mirrored repo. + If not provided, will be generated based on server and repo name. + + Returns: + Created Repo model instance + + Raises: + ValueError: If server_id is invalid + """ + # Verify server exists + server = self.db.query(Server).filter_by(id=server_id).first() + if not server: + raise ValueError(f"Server not found with ID {server_id}") + + # Generate local_path if not provided + if local_path is None: + local_path = str(Path(server.local_path) / name) + + # Create the repo record + current_time = int(time.time()) + repo = Repo( + server_id=server_id, + name=name, + full_name=full_name, + clone_url=clone_url, + local_path=local_path, + status="pending", + last_sync_at=None, + created_at=current_time + ) + + self.db.add(repo) + self.db.commit() + self.db.refresh(repo) + + return repo + + def list_repos(self, server_id: int) -> List[Repo]: + """ + List all repositories for a specific server. + + Args: + server_id: ID of the server + + Returns: + List of Repo model instances for the server, ordered by creation time + """ + return self.db.query(Repo).filter_by( + server_id=server_id + ).order_by(Repo.created_at).all() + + def get_repo(self, repo_id: int) -> Optional[Repo]: + """ + Get a repository by ID. + + Args: + repo_id: ID of the repository + + Returns: + Repo model instance or None if not found + """ + return self.db.query(Repo).filter_by(id=repo_id).first() + + def update_repo_status(self, repo_id: int, status: str) -> Repo: + """ + Update the status of a repository. + + Common status values: + - "pending": Initial state, not yet synced + - "syncing": Currently being synced + - "success": Last sync was successful + - "failed": Last sync failed + + Args: + repo_id: ID of the repository + status: New status value + + Returns: + Updated Repo model instance + + Raises: + ValueError: If repo not found + """ + repo = self.get_repo(repo_id) + if not repo: + raise ValueError(f"Repo not found with ID {repo_id}") + + repo.status = status + + # If status is success, update last_sync_at + if status == "success": + repo.last_sync_at = int(time.time()) + + self.db.commit() + self.db.refresh(repo) + + return repo + + def delete_repo(self, repo_id: int) -> bool: + """ + Delete a repository. + + Args: + repo_id: ID of the repository to delete + + Returns: + True if deleted, False if not found + """ + repo = self.get_repo(repo_id) + if not repo: + return False + + self.db.delete(repo) + self.db.commit() + + return True + + def get_repo_by_name(self, server_id: int, name: str) -> Optional[Repo]: + """ + Get a repository by server and name. + + Args: + server_id: ID of the server + name: Repository name + + Returns: + Repo model instance or None if not found + """ + return self.db.query(Repo).filter_by( + server_id=server_id, + name=name + ).first() + + def list_all_repos(self) -> List[Repo]: + """ + List all repositories across all servers. + + Returns: + List of all Repo model instances, ordered by creation time + """ + return self.db.query(Repo).order_by(Repo.created_at).all() + + def update_repo( + self, + repo_id: int, + **kwargs + ) -> Repo: + """ + Update a repository's configuration. + + Args: + repo_id: ID of the repository to update + **kwargs: Fields to update (name, full_name, clone_url, local_path, status) + + Returns: + Updated Repo model instance + + Raises: + ValueError: If repo not found + """ + repo = self.get_repo(repo_id) + if not repo: + raise ValueError(f"Repo not found with ID {repo_id}") + + # Update fields + for key, value in kwargs.items(): + if hasattr(repo, key): + setattr(repo, key, value) + + self.db.commit() + self.db.refresh(repo) + + return repo diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..45e2146 --- /dev/null +++ b/backend/app/services/sync_service.py @@ -0,0 +1,263 @@ +""" +Sync Service. + +Handles Git operations for repository mirroring including: +- Cloning repositories with SSH authentication +- Fetching updates from mirrored repositories +- Counting commits in repositories +- Retrieving commit history +""" +import subprocess +import tempfile +import os +from pathlib import Path +from typing import List, Dict, Optional +from sqlalchemy.orm import Session +import time + +from app.models.repo import Repo + + +class SyncService: + """ + Service for managing Git repository synchronization. + + Handles clone and fetch operations with SSH key authentication. + """ + + def __init__(self, db: Session): + """ + Initialize the service with a database session. + + Args: + db: SQLAlchemy database session + """ + self.db = db + + def sync_repo(self, repo: Repo, ssh_key_content: str) -> None: + """ + Synchronize a repository by cloning or fetching. + + If the repository doesn't exist locally, clone it. + If it exists, fetch all updates. + + Args: + repo: Repo model instance + ssh_key_content: SSH private key content for authentication + + Raises: + Exception: If clone or fetch operation fails + """ + local_path = Path(repo.local_path) + + # Update repo status to syncing + repo.status = "syncing" + repo.last_sync_at = int(time.time()) + self.db.commit() + + if local_path.exists(): + # Repository exists, fetch updates + self._fetch_repo(str(local_path), ssh_key_content) + else: + # Repository doesn't exist, clone it + self._clone_repo(repo.clone_url, str(local_path), ssh_key_content) + + def _clone_repo(self, clone_url: str, local_path: str, ssh_key: str) -> None: + """ + Clone a repository using git clone --mirror. + + Creates a bare mirror clone of the repository. + + Args: + clone_url: Git clone URL (SSH format) + local_path: Local path where repo should be cloned + ssh_key: SSH private key content for authentication + + Raises: + subprocess.CalledProcessError: If git clone fails + IOError: If unable to create temporary SSH key file + """ + # Create a temporary file for SSH key + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.key') as key_file: + key_file.write(ssh_key) + key_file_path = key_file.name + + try: + # Set appropriate permissions for SSH key + os.chmod(key_file_path, 0o600) + + # Create SSH command wrapper that uses our key + ssh_cmd = f'ssh -i {key_file_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' + + # Git clone command with mirror option + git_cmd = [ + 'git', + 'clone', + '--mirror', + clone_url, + local_path + ] + + # Run git clone with SSH authentication + env = os.environ.copy() + env['GIT_SSH_COMMAND'] = ssh_cmd + + result = subprocess.run( + git_cmd, + env=env, + capture_output=True, + text=True, + check=True + ) + + return result + + finally: + # Clean up temporary SSH key file + try: + os.unlink(key_file_path) + except OSError: + pass + + def _fetch_repo(self, local_path: str, ssh_key: str) -> None: + """ + Fetch all updates for an existing repository. + + Args: + local_path: Local path to the repository + ssh_key: SSH private key content for authentication + + Raises: + subprocess.CalledProcessError: If git fetch fails + IOError: If unable to create temporary SSH key file + """ + # Create a temporary file for SSH key + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.key') as key_file: + key_file.write(ssh_key) + key_file_path = key_file.name + + try: + # Set appropriate permissions for SSH key + os.chmod(key_file_path, 0o600) + + # Create SSH command wrapper that uses our key + ssh_cmd = f'ssh -i {key_file_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' + + # Git fetch command to get all updates + git_cmd = [ + 'git', + '--git-dir', + local_path, + 'fetch', + '--all' + ] + + # Run git fetch with SSH authentication + env = os.environ.copy() + env['GIT_SSH_COMMAND'] = ssh_cmd + + result = subprocess.run( + git_cmd, + env=env, + capture_output=True, + text=True, + check=True + ) + + return result + + finally: + # Clean up temporary SSH key file + try: + os.unlink(key_file_path) + except OSError: + pass + + def _count_commits(self, repo_path: str) -> int: + """ + Count the number of commits in a repository. + + Args: + repo_path: Path to the repository + + Returns: + Number of commits, or 0 if counting fails + """ + try: + git_cmd = [ + 'git', + '--git-dir', + repo_path, + 'rev-list', + '--all', + '--count' + ] + + result = subprocess.run( + git_cmd, + capture_output=True, + text=True, + check=True + ) + + return int(result.stdout.strip()) + + except (subprocess.CalledProcessError, ValueError): + return 0 + + def get_repo_commits(self, repo: Repo, limit: int = 100) -> List[Dict[str, str]]: + """ + Get commit history for a repository. + + Args: + repo: Repo model instance + limit: Maximum number of commits to return + + Returns: + List of commit dictionaries containing: + - hash: Commit SHA + - message: Commit message + - author: Author name + - email: Author email + - date: Commit timestamp (Unix timestamp) + """ + repo_path = Path(repo.local_path) + + if not repo_path.exists(): + return [] + + try: + git_cmd = [ + 'git', + '--git-dir', + str(repo_path), + 'log', + '--all', + f'--max-count={limit}', + '--format=%H|%s|%an|%ae|%ct' + ] + + result = subprocess.run( + git_cmd, + capture_output=True, + text=True, + check=True + ) + + commits = [] + for line in result.stdout.strip().split('\n'): + if line: + parts = line.split('|') + if len(parts) == 5: + commits.append({ + 'hash': parts[0], + 'message': parts[1], + 'author': parts[2], + 'email': parts[3], + 'date': int(parts[4]) + }) + + return commits + + except subprocess.CalledProcessError: + return [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2013cea..93d7ffa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,12 +1,13 @@ import sys import pytest from pathlib import Path +from typing import Generator # Add backend to path sys.path.insert(0, str(Path(__file__).parent.parent)) from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import sessionmaker, Session # NOTE: This import will fail until models are created in Task 2.1 # This is expected behavior - the models module doesn't exist yet @@ -58,3 +59,71 @@ def test_env_vars(db_path, test_encrypt_key, monkeypatch): "GM_ENCRYPT_KEY": test_encrypt_key, "GM_API_TOKEN": "test-token", } + + +@pytest.fixture(scope="function") +def client(db_session: Session, test_env_vars: dict, monkeypatch): + """ + FastAPI test client fixture. + + Provides a test client for the FastAPI application with: + - In-memory database session + - Test environment variables + - Disabled lifespan (for faster tests) + + Args: + db_session: Database session fixture + test_env_vars: Test environment variables fixture + monkeypatch: Pytest monkeypatch fixture + + Yields: + FastAPI test client + + Example: + def test_create_ssh_key(client): + response = client.post( + "/api/ssh-keys", + json={"name": "test-key", "private_key": "..."}, + headers={"Authorization": "Bearer test-token"} + ) + assert response.status_code == 201 + """ + from fastapi.testclient import TestClient + from unittest.mock import AsyncMock, patch + + # Mock the lifespan context manager + async def mock_lifespan(app): # noqa: ARG001 - Unused app parameter + # Database is already initialized by db_session fixture + yield + + # Import after setting env vars + import app.database + import app.config + + # Initialize database with test session + app.database._engine = db_session.bind + app.database._session_factory = sessionmaker(bind=db_session.bind, autocommit=False, autoflush=False) + + # Create app with mocked lifespan + from app.main import create_app + test_app = create_app(lifespan_handler=mock_lifespan) + + # Override get_db_session dependency to use test session + from app.api import deps + + def override_get_db_session(): + try: + yield db_session + finally: + pass + + test_app.dependency_overrides[deps.get_db_session] = override_get_db_session + + with TestClient(test_app) as test_client: + yield test_client + + # Clean up + test_app.dependency_overrides = {} + app.database._engine = None + app.database._session_factory = None + app.config._settings = None diff --git a/backend/tests/test_api/__init__.py b/backend/tests/test_api/__init__.py new file mode 100644 index 0000000..e1ef925 --- /dev/null +++ b/backend/tests/test_api/__init__.py @@ -0,0 +1,5 @@ +""" +Tests for API routes. + +This package contains tests for all FastAPI route handlers. +""" diff --git a/backend/tests/test_api/test_servers_api.py b/backend/tests/test_api/test_servers_api.py new file mode 100644 index 0000000..ed51031 --- /dev/null +++ b/backend/tests/test_api/test_servers_api.py @@ -0,0 +1,495 @@ +""" +Tests for Servers API routes. + +Tests the following endpoints: +- POST /api/servers +- GET /api/servers +- GET /api/servers/{id} +- PUT /api/servers/{id} +- DELETE /api/servers/{id} +""" +import pytest +from fastapi.testclient import TestClient + + +# Valid SSH private key for testing servers +VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR +AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA +AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH +AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg==""" + + +class TestCreateServer: + """Tests for POST /api/servers endpoint.""" + + def test_create_server_success(self, client: TestClient): + """Test creating a new server successfully.""" + # First create an SSH key + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + # Create server + response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-api-token-123", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 201 + data = response.json() + assert data["code"] == 0 + assert data["message"] == "Server created successfully" + assert data["data"]["name"] == "test-server" + assert data["data"]["url"] == "https://gitea.example.com" + assert data["data"]["ssh_key_id"] == ssh_key_id + assert data["data"]["id"] > 0 + assert data["data"]["status"] == "untested" + assert data["data"]["sync_enabled"] is False + assert data["data"]["created_at"] > 0 + + def test_create_server_with_sync_enabled(self, client: TestClient): + """Test creating a server with sync enabled.""" + # Create SSH key + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-2", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + # Create server with sync enabled + response = client.post( + "/api/servers", + json={ + "name": "sync-server", + "url": "https://gitea.example.com", + "api_token": "test-api-token", + "ssh_key_id": ssh_key_id, + "sync_enabled": True, + "schedule_cron": "0 */6 * * *" + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 201 + data = response.json() + assert data["data"]["sync_enabled"] is True + assert data["data"]["schedule_cron"] == "0 */6 * * *" + + def test_create_server_duplicate_name(self, client: TestClient): + """Test creating a server with duplicate name fails.""" + # Create SSH key + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-3", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + # Create first server + client.post( + "/api/servers", + json={ + "name": "duplicate-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + # Try to create duplicate + response = client.post( + "/api/servers", + json={ + "name": "duplicate-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + + def test_create_server_invalid_ssh_key_id(self, client: TestClient): + """Test creating a server with non-existent SSH key fails.""" + response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": 99999 # Non-existent + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + assert "not found" in response.json()["detail"] + + def test_create_server_no_auth(self, client: TestClient): + """Test creating a server without authentication fails.""" + response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": 1 + } + ) + + assert response.status_code == 401 + + +class TestListServers: + """Tests for GET /api/servers endpoint.""" + + def test_list_servers_empty(self, client: TestClient): + """Test listing servers when none exist.""" + response = client.get( + "/api/servers", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"] == [] + assert "0 server" in data["message"] + + def test_list_servers_with_items(self, client: TestClient): + """Test listing servers when some exist.""" + # Create SSH key + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-list", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + # Create some servers + client.post( + "/api/servers", + json={ + "name": "server-1", + "url": "https://gitea1.example.com", + "api_token": "token1", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + client.post( + "/api/servers", + json={ + "name": "server-2", + "url": "https://gitea2.example.com", + "api_token": "token2", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + response = client.get( + "/api/servers", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert len(data["data"]) == 2 + assert "api_token" not in data["data"][0] # Should not expose token + + def test_list_servers_no_auth(self, client: TestClient): + """Test listing servers without authentication fails.""" + response = client.get("/api/servers") + + assert response.status_code == 401 + + +class TestGetServer: + """Tests for GET /api/servers/{id} endpoint.""" + + def test_get_server_success(self, client: TestClient): + """Test getting a specific server successfully.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-get", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "get-test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Get the server + response = client.get( + f"/api/servers/{server_id}", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["id"] == server_id + assert data["data"]["name"] == "get-test-server" + assert "api_token" not in data["data"] + + def test_get_server_not_found(self, client: TestClient): + """Test getting a non-existent server fails.""" + response = client.get( + "/api/servers/99999", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 404 + + def test_get_server_no_auth(self, client: TestClient): + """Test getting a server without authentication fails.""" + response = client.get("/api/servers/1") + + assert response.status_code == 401 + + +class TestUpdateServer: + """Tests for PUT /api/servers/{id} endpoint.""" + + def test_update_server_name(self, client: TestClient): + """Test updating a server's name.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-update", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "old-name", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Update name + response = client.put( + f"/api/servers/{server_id}", + json={"name": "new-name"}, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["name"] == "new-name" + + def test_update_server_multiple_fields(self, client: TestClient): + """Test updating multiple server fields.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-multi", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Update multiple fields + response = client.put( + f"/api/servers/{server_id}", + json={ + "sync_enabled": True, + "schedule_cron": "0 */6 * * *", + "status": "testing" + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["sync_enabled"] is True + assert data["data"]["schedule_cron"] == "0 */6 * * *" + assert data["data"]["status"] == "testing" + + def test_update_server_api_token(self, client: TestClient): + """Test updating a server's API token.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-token", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "old-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Update API token + response = client.put( + f"/api/servers/{server_id}", + json={"api_token": "new-token"}, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + + def test_update_server_not_found(self, client: TestClient): + """Test updating a non-existent server fails.""" + response = client.put( + "/api/servers/99999", + json={"name": "new-name"}, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 404 + + def test_update_server_no_fields(self, client: TestClient): + """Test updating a server with no fields fails.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-nofield", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Update with empty body + response = client.put( + f"/api/servers/{server_id}", + json={}, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + + def test_update_server_no_auth(self, client: TestClient): + """Test updating a server without authentication fails.""" + response = client.put( + "/api/servers/1", + json={"name": "new-name"} + ) + + assert response.status_code == 401 + + +class TestDeleteServer: + """Tests for DELETE /api/servers/{id} endpoint.""" + + def test_delete_server_success(self, client: TestClient): + """Test deleting a server successfully.""" + # Create SSH key and server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "test-key-del", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + server_response = client.post( + "/api/servers", + json={ + "name": "delete-test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + server_id = server_response.json()["data"]["id"] + + # Delete the server + response = client.delete( + f"/api/servers/{server_id}", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["message"] == "Server deleted successfully" + + # Verify it's deleted + get_response = client.get( + f"/api/servers/{server_id}", + headers={"Authorization": "Bearer test-token"} + ) + assert get_response.status_code == 404 + + def test_delete_server_not_found(self, client: TestClient): + """Test deleting a non-existent server fails.""" + response = client.delete( + "/api/servers/99999", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 404 + + def test_delete_server_no_auth(self, client: TestClient): + """Test deleting a server without authentication fails.""" + response = client.delete("/api/servers/1") + + assert response.status_code == 401 diff --git a/backend/tests/test_api/test_ssh_keys_api.py b/backend/tests/test_api/test_ssh_keys_api.py new file mode 100644 index 0000000..ec9abe1 --- /dev/null +++ b/backend/tests/test_api/test_ssh_keys_api.py @@ -0,0 +1,299 @@ +""" +Tests for SSH Keys API routes. + +Tests the following endpoints: +- POST /api/ssh-keys +- GET /api/ssh-keys +- GET /api/ssh-keys/{id} +- DELETE /api/ssh-keys/{id} +""" +import pytest +from fastapi.testclient import TestClient + + +# Valid SSH private key for testing +VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR +AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA +AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH +AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg==""" + +# Invalid SSH key for testing +INVALID_SSH_KEY = "not-a-valid-ssh-key" + + +class TestCreateSshKey: + """Tests for POST /api/ssh-keys endpoint.""" + + def test_create_ssh_key_success(self, client: TestClient): + """Test creating a new SSH key successfully.""" + response = client.post( + "/api/ssh-keys", + json={ + "name": "test-key", + "private_key": VALID_SSH_KEY + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 201 + data = response.json() + assert data["code"] == 0 + assert data["message"] == "SSH key created successfully" + assert data["data"]["name"] == "test-key" + assert data["data"]["id"] > 0 + assert data["data"]["fingerprint"] is not None + assert data["data"]["created_at"] > 0 + + def test_create_ssh_key_duplicate_name(self, client: TestClient): + """Test creating a SSH key with duplicate name fails.""" + # Create first key + client.post( + "/api/ssh-keys", + json={ + "name": "duplicate-key", + "private_key": VALID_SSH_KEY + }, + headers={"Authorization": "Bearer test-token"} + ) + + # Try to create duplicate + response = client.post( + "/api/ssh-keys", + json={ + "name": "duplicate-key", + "private_key": VALID_SSH_KEY + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + data = response.json() + assert "already exists" in data["detail"] + + def test_create_ssh_key_invalid_key_format(self, client: TestClient): + """Test creating a SSH key with invalid format fails.""" + response = client.post( + "/api/ssh-keys", + json={ + "name": "invalid-key", + "private_key": INVALID_SSH_KEY + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + data = response.json() + assert "Invalid SSH private key format" in data["detail"] + + def test_create_ssh_key_empty_name(self, client: TestClient): + """Test creating a SSH key with empty name fails.""" + response = client.post( + "/api/ssh-keys", + json={ + "name": "", + "private_key": VALID_SSH_KEY + }, + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 422 # Validation error + + def test_create_ssh_key_no_auth(self, client: TestClient): + """Test creating a SSH key without authentication fails.""" + response = client.post( + "/api/ssh-keys", + json={ + "name": "test-key", + "private_key": VALID_SSH_KEY + } + ) + + assert response.status_code == 401 + + def test_create_ssh_key_invalid_token(self, client: TestClient): + """Test creating a SSH key with invalid token fails.""" + response = client.post( + "/api/ssh-keys", + json={ + "name": "test-key", + "private_key": VALID_SSH_KEY + }, + headers={"Authorization": "Bearer invalid-token"} + ) + + assert response.status_code == 401 + + +class TestListSshKeys: + """Tests for GET /api/ssh-keys endpoint.""" + + def test_list_ssh_keys_empty(self, client: TestClient): + """Test listing SSH keys when none exist.""" + response = client.get( + "/api/ssh-keys", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"] == [] + assert "0 SSH key" in data["message"] + + def test_list_ssh_keys_with_items(self, client: TestClient): + """Test listing SSH keys when some exist.""" + # Create some keys + client.post( + "/api/ssh-keys", + json={"name": "key-1", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + client.post( + "/api/ssh-keys", + json={"name": "key-2", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + + response = client.get( + "/api/ssh-keys", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert len(data["data"]) == 2 + assert data["data"][0]["name"] in ["key-1", "key-2"] + assert "private_key" not in data["data"][0] # Should not expose private key + + def test_list_ssh_keys_no_auth(self, client: TestClient): + """Test listing SSH keys without authentication fails.""" + response = client.get("/api/ssh-keys") + + assert response.status_code == 401 + + +class TestGetSshKey: + """Tests for GET /api/ssh-keys/{id} endpoint.""" + + def test_get_ssh_key_success(self, client: TestClient): + """Test getting a specific SSH key successfully.""" + # Create a key first + create_response = client.post( + "/api/ssh-keys", + json={"name": "get-test-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + key_id = create_response.json()["data"]["id"] + + # Get the key + response = client.get( + f"/api/ssh-keys/{key_id}", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["id"] == key_id + assert data["data"]["name"] == "get-test-key" + assert "private_key" not in data["data"] + + def test_get_ssh_key_not_found(self, client: TestClient): + """Test getting a non-existent SSH key fails.""" + response = client.get( + "/api/ssh-keys/99999", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + def test_get_ssh_key_no_auth(self, client: TestClient): + """Test getting an SSH key without authentication fails.""" + response = client.get("/api/ssh-keys/1") + + assert response.status_code == 401 + + +class TestDeleteSshKey: + """Tests for DELETE /api/ssh-keys/{id} endpoint.""" + + def test_delete_ssh_key_success(self, client: TestClient): + """Test deleting an SSH key successfully.""" + # Create a key first + create_response = client.post( + "/api/ssh-keys", + json={"name": "delete-test-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + key_id = create_response.json()["data"]["id"] + + # Delete the key + response = client.delete( + f"/api/ssh-keys/{key_id}", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["message"] == "SSH key deleted successfully" + + # Verify it's deleted + get_response = client.get( + f"/api/ssh-keys/{key_id}", + headers={"Authorization": "Bearer test-token"} + ) + assert get_response.status_code == 404 + + def test_delete_ssh_key_not_found(self, client: TestClient): + """Test deleting a non-existent SSH key fails.""" + response = client.delete( + "/api/ssh-keys/99999", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 404 + + def test_delete_ssh_key_in_use(self, client: TestClient): + """Test deleting an SSH key that is in use by a server fails.""" + # Create an SSH key + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "in-use-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + # Create a server using this key + client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-api-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + # Try to delete the SSH key + response = client.delete( + f"/api/ssh-keys/{ssh_key_id}", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 400 + assert "in use" in response.json()["detail"].lower() + + def test_delete_ssh_key_no_auth(self, client: TestClient): + """Test deleting an SSH key without authentication fails.""" + response = client.delete("/api/ssh-keys/1") + + assert response.status_code == 401 diff --git a/backend/tests/test_api/test_status_api.py b/backend/tests/test_api/test_status_api.py new file mode 100644 index 0000000..6ac0a2c --- /dev/null +++ b/backend/tests/test_api/test_status_api.py @@ -0,0 +1,151 @@ +""" +Tests for Status API routes. + +Tests the following endpoints: +- GET /api/status +- GET /api/status/health +""" +import pytest +from fastapi.testclient import TestClient + + +VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAJC9AVH1vQFR +AAAAAtzc2gtZWQyNTUxOQAAACB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQA +AAECB5WfkA8wgP/KvdGFNC1ZCbBmjZnKpM/LOXRDJS7NfRAQAAAHHZpXRvaXRvQVJNVjJH +AAAAFGZpbGVzeXN0ZW0uY2ZnAAAAAQAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAA/////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAEAAAAEWNvbW1lbnQ6IHRlc3Qga2V5AQIDBAUHBg==""" + + +class TestGetStatus: + """Tests for GET /api/status endpoint.""" + + def test_get_status_unauthenticated(self, client: TestClient): + """Test getting status without authentication.""" + response = client.get("/api/status") + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["message"] == "System status retrieved successfully" + assert data["data"]["status"] == "healthy" + assert data["data"]["version"] == "1.0.0" + assert data["data"]["authenticated"] is False + assert "database" in data["data"] + assert data["data"]["database"]["status"] == "connected" + # Storage paths should not be included for unauthenticated + assert "storage" not in data["data"] + + def test_get_status_authenticated(self, client: TestClient): + """Test getting status with authentication.""" + response = client.get( + "/api/status", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["authenticated"] is True + assert "storage" in data["data"] + assert "data_dir" in data["data"]["storage"] + assert "repos_dir" in data["data"]["storage"] + assert "ssh_keys_dir" in data["data"]["storage"] + assert "db_path" in data["data"]["storage"] + + def test_get_status_with_data(self, client: TestClient): + """Test getting status when data exists.""" + # Create an SSH key + client.post( + "/api/ssh-keys", + json={"name": "test-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + + # Get status + response = client.get( + "/api/status", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["database"]["ssh_keys_count"] == 1 + assert data["data"]["database"]["servers_count"] == 0 + assert data["data"]["database"]["repos_count"] == 0 + + def test_get_status_database_counts(self, client: TestClient): + """Test database counts in status are accurate.""" + # Create multiple items + client.post( + "/api/ssh-keys", + json={"name": "key-1", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + client.post( + "/api/ssh-keys", + json={"name": "key-2", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + + # Create a server + ssh_response = client.post( + "/api/ssh-keys", + json={"name": "server-key", "private_key": VALID_SSH_KEY}, + headers={"Authorization": "Bearer test-token"} + ) + ssh_key_id = ssh_response.json()["data"]["id"] + + client.post( + "/api/servers", + json={ + "name": "test-server", + "url": "https://gitea.example.com", + "api_token": "test-token", + "ssh_key_id": ssh_key_id + }, + headers={"Authorization": "Bearer test-token"} + ) + + # Get status + response = client.get( + "/api/status", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["database"]["ssh_keys_count"] == 3 + assert data["data"]["database"]["servers_count"] == 1 + + +class TestHealthCheck: + """Tests for GET /api/status/health endpoint.""" + + def test_health_check(self, client: TestClient): + """Test the health check endpoint.""" + response = client.get("/api/status/health") + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["status"] == "ok" + assert data["message"] == "Service is healthy" + + def test_health_check_no_auth_required(self, client: TestClient): + """Test health check works without authentication.""" + response = client.get("/api/status/health") + + assert response.status_code == 200 + + def test_health_check_with_auth(self, client: TestClient): + """Test health check works with authentication.""" + response = client.get( + "/api/status/health", + headers={"Authorization": "Bearer test-token"} + ) + + assert response.status_code == 200 diff --git a/backend/tests/test_services/test_repo_service.py b/backend/tests/test_services/test_repo_service.py new file mode 100644 index 0000000..ff9d714 --- /dev/null +++ b/backend/tests/test_services/test_repo_service.py @@ -0,0 +1,411 @@ +""" +Tests for Repo Service. +""" +import base64 +import pytest +import time +from pathlib import Path +from app.models.repo import Repo +from app.models.server import Server +from app.services.repo_service import RepoService +from app.services.server_service import ServerService +from app.services.ssh_key_service import SshKeyService + + +# Valid test SSH key +VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk +AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA +AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E +JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ= +-----END OPENSSH PRIVATE KEY----- +""" + + +def create_test_server_with_repo(db_session, server_name="test-server"): + """Helper to create a test server.""" + ssh_service = SshKeyService(db_session) + ssh_key = ssh_service.create_ssh_key(name="test-ssh-key", private_key=VALID_SSH_KEY) + + server_service = ServerService(db_session) + return server_service.create_server( + name=server_name, + url="https://gitea.example.com", + api_token="test-api-token", + ssh_key_id=ssh_key.id, + sync_enabled=False, + schedule_cron=None + ) + + +class TestCreateRepo: + """Tests for create_repo method.""" + + def test_create_repo_success(self, db_session, test_env_vars): + """Test successful repository creation.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="test-repo", + full_name="owner/test-repo", + clone_url="git@gitea.example.com:owner/test-repo.git", + local_path="/tmp/test-repo" + ) + + assert repo.id is not None + assert repo.server_id == server.id + assert repo.name == "test-repo" + assert repo.full_name == "owner/test-repo" + assert repo.clone_url == "git@gitea.example.com:owner/test-repo.git" + assert repo.local_path == "/tmp/test-repo" + assert repo.status == "pending" + assert repo.last_sync_at is None + assert repo.created_at is not None + + def test_create_repo_with_invalid_server_id(self, db_session, test_env_vars): + """Test that invalid server_id is rejected.""" + service = RepoService(db_session) + + with pytest.raises(ValueError, match="Server not found"): + service.create_repo( + server_id=99999, + name="test-repo", + full_name="owner/test-repo", + clone_url="git@gitea.example.com:owner/test-repo.git", + local_path="/tmp/test-repo" + ) + + def test_create_repo_generates_local_path(self, db_session, test_env_vars): + """Test that local_path can be generated correctly.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + # Test with None local_path to let it generate + repo = service.create_repo( + server_id=server.id, + name="auto-path-repo", + full_name="owner/auto-path-repo", + clone_url="git@gitea.example.com:owner/auto-path-repo.git", + local_path=None + ) + + # Should generate path based on server and repo name + assert repo.local_path is not None + assert "auto-path-repo" in repo.local_path + + def test_create_repo_duplicate_name_same_server(self, db_session, test_env_vars): + """Test that duplicate repo names on same server are allowed (different from servers).""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + # Create first repo + service.create_repo( + server_id=server.id, + name="duplicate-repo", + full_name="owner1/duplicate-repo", + clone_url="git@gitea.example.com:owner1/duplicate-repo.git", + local_path="/tmp/duplicate-repo-1" + ) + + # Create second repo with same name but different full_name + repo2 = service.create_repo( + server_id=server.id, + name="duplicate-repo", + full_name="owner2/duplicate-repo", + clone_url="git@gitea.example.com:owner2/duplicate-repo.git", + local_path="/tmp/duplicate-repo-2" + ) + + assert repo2 is not None + assert repo2.full_name == "owner2/duplicate-repo" + + def test_create_repo_default_status(self, db_session, test_env_vars): + """Test that new repos have default 'pending' status.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="status-test-repo", + full_name="owner/status-test-repo", + clone_url="git@gitea.example.com:owner/status-test-repo.git", + local_path="/tmp/status-test-repo" + ) + + assert repo.status == "pending" + + +class TestListRepos: + """Tests for list_repos method.""" + + def test_list_repos_empty(self, db_session, test_env_vars): + """Test listing repos when none exist.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repos = service.list_repos(server.id) + + assert repos == [] + + def test_list_repos_multiple(self, db_session, test_env_vars): + """Test listing multiple repos for a server.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + service.create_repo( + server_id=server.id, + name="repo-1", + full_name="owner/repo-1", + clone_url="git@gitea.example.com:owner/repo-1.git", + local_path="/tmp/repo-1" + ) + service.create_repo( + server_id=server.id, + name="repo-2", + full_name="owner/repo-2", + clone_url="git@gitea.example.com:owner/repo-2.git", + local_path="/tmp/repo-2" + ) + + repos = service.list_repos(server.id) + + assert len(repos) == 2 + assert any(r.name == "repo-1" for r in repos) + assert any(r.name == "repo-2" for r in repos) + + def test_list_repos_filters_by_server(self, db_session, test_env_vars): + """Test that list_repos only returns repos for specified server.""" + server1 = create_test_server_with_repo(db_session, "server-1") + server2 = create_test_server_with_repo(db_session, "server-2") + service = RepoService(db_session) + + # Create repo for server1 + service.create_repo( + server_id=server1.id, + name="server1-repo", + full_name="owner/server1-repo", + clone_url="git@gitea.example.com:owner/server1-repo.git", + local_path="/tmp/server1-repo" + ) + + # Create repo for server2 + service.create_repo( + server_id=server2.id, + name="server2-repo", + full_name="owner/server2-repo", + clone_url="git@gitea.example.com:owner/server2-repo.git", + local_path="/tmp/server2-repo" + ) + + # List repos for server1 should only return server1's repo + server1_repos = service.list_repos(server1.id) + assert len(server1_repos) == 1 + assert server1_repos[0].name == "server1-repo" + + # List repos for server2 should only return server2's repo + server2_repos = service.list_repos(server2.id) + assert len(server2_repos) == 1 + assert server2_repos[0].name == "server2-repo" + + def test_list_repos_ordered_by_creation(self, db_session, test_env_vars): + """Test that repos are listed in creation order.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo1 = service.create_repo( + server_id=server.id, + name="first-repo", + full_name="owner/first-repo", + clone_url="git@gitea.example.com:owner/first-repo.git", + local_path="/tmp/first-repo" + ) + + time.sleep(0.1) # Small delay to ensure timestamp difference + + repo2 = service.create_repo( + server_id=server.id, + name="second-repo", + full_name="owner/second-repo", + clone_url="git@gitea.example.com:owner/second-repo.git", + local_path="/tmp/second-repo" + ) + + repos = service.list_repos(server.id) + + assert repos[0].id == repo1.id + assert repos[1].id == repo2.id + + +class TestGetRepo: + """Tests for get_repo method.""" + + def test_get_repo_by_id(self, db_session, test_env_vars): + """Test getting a repo by ID.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + created_repo = service.create_repo( + server_id=server.id, + name="get-test-repo", + full_name="owner/get-test-repo", + clone_url="git@gitea.example.com:owner/get-test-repo.git", + local_path="/tmp/get-test-repo" + ) + + retrieved_repo = service.get_repo(created_repo.id) + + assert retrieved_repo is not None + assert retrieved_repo.id == created_repo.id + assert retrieved_repo.name == "get-test-repo" + + def test_get_repo_not_found(self, db_session, test_env_vars): + """Test getting a non-existent repo.""" + service = RepoService(db_session) + + repo = service.get_repo(99999) + + assert repo is None + + +class TestUpdateRepoStatus: + """Tests for update_repo_status method.""" + + def test_update_repo_status_to_syncing(self, db_session, test_env_vars): + """Test updating repo status to syncing.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="status-repo", + full_name="owner/status-repo", + clone_url="git@gitea.example.com:owner/status-repo.git", + local_path="/tmp/status-repo" + ) + + updated_repo = service.update_repo_status(repo.id, "syncing") + + assert updated_repo.status == "syncing" + assert updated_repo.id == repo.id + + def test_update_repo_status_to_success(self, db_session, test_env_vars): + """Test updating repo status to success.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="status-repo", + full_name="owner/status-repo", + clone_url="git@gitea.example.com:owner/status-repo.git", + local_path="/tmp/status-repo" + ) + + updated_repo = service.update_repo_status(repo.id, "success") + + assert updated_repo.status == "success" + + def test_update_repo_status_to_failed(self, db_session, test_env_vars): + """Test updating repo status to failed.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="status-repo", + full_name="owner/status-repo", + clone_url="git@gitea.example.com:owner/status-repo.git", + local_path="/tmp/status-repo" + ) + + updated_repo = service.update_repo_status(repo.id, "failed") + + assert updated_repo.status == "failed" + + def test_update_repo_status_not_found(self, db_session, test_env_vars): + """Test updating status for non-existent repo.""" + service = RepoService(db_session) + + with pytest.raises(ValueError, match="Repo not found"): + service.update_repo_status(99999, "syncing") + + +class TestDeleteRepo: + """Tests for delete_repo method.""" + + def test_delete_repo_success(self, db_session, test_env_vars): + """Test successful repo deletion.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + repo = service.create_repo( + server_id=server.id, + name="delete-test-repo", + full_name="owner/delete-test-repo", + clone_url="git@gitea.example.com:owner/delete-test-repo.git", + local_path="/tmp/delete-test-repo" + ) + + result = service.delete_repo(repo.id) + + assert result is True + + # Verify the repo is deleted + retrieved_repo = service.get_repo(repo.id) + assert retrieved_repo is None + + def test_delete_repo_not_found(self, db_session, test_env_vars): + """Test deleting a non-existent repo.""" + service = RepoService(db_session) + + result = service.delete_repo(99999) + + assert result is False + + +class TestIntegration: + """Integration tests for Repo service.""" + + def test_repo_lifecycle(self, db_session, test_env_vars): + """Test complete lifecycle of a repo.""" + server = create_test_server_with_repo(db_session) + service = RepoService(db_session) + + # Create repo + repo = service.create_repo( + server_id=server.id, + name="lifecycle-repo", + full_name="owner/lifecycle-repo", + clone_url="git@gitea.example.com:owner/lifecycle-repo.git", + local_path="/tmp/lifecycle-repo" + ) + assert repo.status == "pending" + + # Update status to syncing + repo = service.update_repo_status(repo.id, "syncing") + assert repo.status == "syncing" + + # Update status to success + repo = service.update_repo_status(repo.id, "success") + assert repo.status == "success" + + # Verify we can retrieve it + retrieved = service.get_repo(repo.id) + assert retrieved is not None + assert retrieved.status == "success" + + # List repos for server + repos = service.list_repos(server.id) + assert len(repos) == 1 + assert repos[0].id == repo.id + + # Delete repo + result = service.delete_repo(repo.id) + assert result is True + + # Verify it's gone + repos = service.list_repos(server.id) + assert len(repos) == 0 diff --git a/backend/tests/test_services/test_sync_service.py b/backend/tests/test_services/test_sync_service.py new file mode 100644 index 0000000..c9aaa23 --- /dev/null +++ b/backend/tests/test_services/test_sync_service.py @@ -0,0 +1,323 @@ +""" +Tests for Sync Service. +""" +import base64 +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock, call +from app.models.repo import Repo +from app.models.server import Server +from app.models.ssh_key import SshKey +from app.services.sync_service import SyncService + + +# Valid test SSH key +VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk +AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA +AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E +JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ= +-----END OPENSSH PRIVATE KEY----- +""" + + +def create_test_server(db_session, name="test-server"): + """Helper to create a test server.""" + from app.services.ssh_key_service import SshKeyService + from app.services.server_service import ServerService + + ssh_service = SshKeyService(db_session) + ssh_key = ssh_service.create_ssh_key(name="test-ssh-key", private_key=VALID_SSH_KEY) + + server_service = ServerService(db_session) + return server_service.create_server( + name=name, + url="https://gitea.example.com", + api_token="test-api-token", + ssh_key_id=ssh_key.id, + sync_enabled=False, + schedule_cron=None + ) + + +def create_test_repo(db_session, server_id=None, name="test-repo", local_path=None): + """Helper to create a test repo.""" + if server_id is None: + server = create_test_server(db_session) + server_id = server.id + + import time + repo = Repo( + server_id=server_id, + name=name, + full_name=f"owner/{name}", + clone_url="git@gitea.example.com:owner/test-repo.git", + local_path=local_path or "/tmp/test-repo", + status="pending", + created_at=int(time.time()) + ) + db_session.add(repo) + db_session.commit() + db_session.refresh(repo) + return repo + + +class TestSyncRepo: + """Tests for sync_repo method.""" + + def test_sync_repo_clones_new_repo(self, db_session, test_env_vars, tmp_path): + """Test syncing a new repository triggers clone.""" + repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo")) + service = SyncService(db_session) + + with patch.object(service, '_clone_repo') as mock_clone: + service.sync_repo(repo, VALID_SSH_KEY) + mock_clone.assert_called_once() + + def test_sync_repo_fetches_existing_repo(self, db_session, test_env_vars, tmp_path): + """Test syncing an existing repository triggers fetch.""" + repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo")) + # Create the directory to simulate existing repo + tmp_path.joinpath("test-repo").mkdir() + + service = SyncService(db_session) + + with patch.object(service, '_fetch_repo') as mock_fetch: + with patch('pathlib.Path.exists', return_value=True): + service.sync_repo(repo, VALID_SSH_KEY) + mock_fetch.assert_called_once() + + def test_sync_repo_updates_status_to_syncing(self, db_session, test_env_vars, tmp_path): + """Test that sync_repo updates repo status to syncing.""" + repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo")) + service = SyncService(db_session) + + with patch.object(service, '_clone_repo'): + service.sync_repo(repo, VALID_SSH_KEY) + assert repo.status == "syncing" + + def test_sync_repo_updates_last_sync_at(self, db_session, test_env_vars, tmp_path): + """Test that sync_repo updates last_sync_at timestamp.""" + import time + repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo")) + service = SyncService(db_session) + + with patch.object(service, '_clone_repo'): + before_sync = int(time.time()) + service.sync_repo(repo, VALID_SSH_KEY) + after_sync = int(time.time()) + + assert before_sync <= repo.last_sync_at <= after_sync + + +class TestCloneRepo: + """Tests for _clone_repo method.""" + + def test_clone_repo_calls_git_command(self, db_session, test_env_vars): + """Test that _clone_repo calls git clone with correct arguments.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(returncode=0) + + service._clone_repo( + "git@gitea.example.com:owner/repo.git", + "/tmp/repo", + VALID_SSH_KEY + ) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args + assert 'git' in call_args[0][0] + + def test_clone_repo_creates_ssh_wrapper(self, db_session, test_env_vars, tmp_path): + """Test that _clone_repo creates SSH key wrapper.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(returncode=0) + with patch('tempfile.NamedTemporaryFile') as mock_temp: + mock_temp.return_value.__enter__.return_value.name = "/tmp/test_key" + + service._clone_repo( + "git@gitea.example.com:owner/repo.git", + str(tmp_path / "repo"), + VALID_SSH_KEY + ) + + # Verify temp file was created + mock_temp.assert_called() + + def test_clone_repo_handles_failure(self, db_session, test_env_vars, tmp_path): + """Test that _clone_repo handles git clone failures.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, 'git') + + with pytest.raises(Exception): + service._clone_repo( + "git@gitea.example.com:owner/repo.git", + str(tmp_path / "repo"), + VALID_SSH_KEY + ) + + +class TestFetchRepo: + """Tests for _fetch_repo method.""" + + def test_fetch_repo_calls_git_fetch(self, db_session, test_env_vars): + """Test that _fetch_repo calls git fetch --all.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.return_value = Mock(returncode=0) + + service._fetch_repo("/tmp/repo", VALID_SSH_KEY) + + # Verify subprocess.run was called + assert mock_run.called + call_args = mock_run.call_args + assert 'git' in call_args[0][0] + + def test_fetch_repo_handles_failure(self, db_session, test_env_vars): + """Test that _fetch_repo handles git fetch failures.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, 'git') + + with pytest.raises(Exception): + service._fetch_repo("/tmp/repo", VALID_SSH_KEY) + + +class TestCountCommits: + """Tests for _count_commits method.""" + + def test_count_commits_returns_zero_for_nonexistent_repo(self, db_session, test_env_vars): + """Test that _count_commits returns 0 for non-existent repo.""" + service = SyncService(db_session) + + count = service._count_commits("/nonexistent/repo") + + assert count == 0 + + def test_count_commits_parses_git_output(self, db_session, test_env_vars, tmp_path): + """Test that _count_commits parses git rev-list output.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + # Simulate git output with commit count + result = Mock() + result.stdout.decode.return_value = "a1b2c3d\ne4f5g6h\n" + result.returncode = 0 + mock_run.return_value = result + + count = service._count_commits(str(tmp_path)) + + assert count == 2 + + def test_count_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path): + """Test that _count_commits handles git command failure.""" + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, 'git') + + count = service._count_commits(str(tmp_path)) + + assert count == 0 + + +class TestGetRepoCommits: + """Tests for get_repo_commits method.""" + + def test_get_repo_commits_returns_empty_list_for_nonexistent_repo(self, db_session, test_env_vars): + """Test that get_repo_commits returns empty list for non-existent repo.""" + repo = create_test_repo(db_session, local_path="/nonexistent/repo") + service = SyncService(db_session) + + commits = service.get_repo_commits(repo, limit=10) + + assert commits == [] + + def test_get_repo_commits_parses_git_log_output(self, db_session, test_env_vars, tmp_path): + """Test that get_repo_commits parses git log output.""" + repo = create_test_repo(db_session, local_path=str(tmp_path)) + service = SyncService(db_session) + + git_log_output = """a1b2c3d4|First commit|author1|author1@example.com|1000000 +e4f5g6h7|Second commit|author2|author2@example.com|2000000""" + + with patch('subprocess.run') as mock_run: + result = Mock() + result.stdout.decode.return_value = git_log_output + result.returncode = 0 + mock_run.return_value = result + + commits = service.get_repo_commits(repo, limit=10) + + assert len(commits) == 2 + assert commits[0]['hash'] == 'a1b2c3d4' + assert commits[0]['message'] == 'First commit' + assert commits[1]['hash'] == 'e4f5g6h7' + assert commits[1]['message'] == 'Second commit' + + def test_get_repo_commits_respects_limit(self, db_session, test_env_vars, tmp_path): + """Test that get_repo_commits respects the limit parameter.""" + repo = create_test_repo(db_session, local_path=str(tmp_path)) + service = SyncService(db_session) + + git_log_output = """a1b2c3d4|Commit 1|author1|author1@example.com|1000000 +e4f5g6h7|Commit 2|author2|author2@example.com|2000000 +i7j8k9l0|Commit 3|author3|author3@example.com|3000000""" + + with patch('subprocess.run') as mock_run: + result = Mock() + result.stdout.decode.return_value = git_log_output + result.returncode = 0 + mock_run.return_value = result + + commits = service.get_repo_commits(repo, limit=2) + + assert len(commits) == 2 + + def test_get_repo_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path): + """Test that get_repo_commits handles git command failure.""" + repo = create_test_repo(db_session, local_path=str(tmp_path)) + service = SyncService(db_session) + + with patch('subprocess.run') as mock_run: + mock_run.side_effect = subprocess.CalledProcessError(1, 'git') + + commits = service.get_repo_commits(repo, limit=10) + + assert commits == [] + + +class TestIntegration: + """Integration tests for Sync service.""" + + def test_sync_and_get_commits_workflow(self, db_session, test_env_vars, tmp_path): + """Test complete workflow of syncing and getting commits.""" + repo = create_test_repo(db_session, local_path=str(tmp_path / "repo")) + service = SyncService(db_session) + + with patch.object(service, '_clone_repo'): + service.sync_repo(repo, VALID_SSH_KEY) + + # Simulate commits exist + git_log_output = "a1b2c3d4|Test commit|author|author@example.com|1000000" + with patch('subprocess.run') as mock_run: + result = Mock() + result.stdout.decode.return_value = git_log_output + result.returncode = 0 + mock_run.return_value = result + + commits = service.get_repo_commits(repo, limit=10) + + assert len(commits) == 1 + assert repo.status == "syncing" diff --git a/backend/verify_implementation.py b/backend/verify_implementation.py new file mode 100644 index 0000000..88dcd96 --- /dev/null +++ b/backend/verify_implementation.py @@ -0,0 +1,111 @@ +""" +Simple verification script for Phase 5 and Phase 6 implementation. + +This script verifies that all required files exist and can be imported. +""" +import sys +from pathlib import Path + +# Add backend to path +sys.path.insert(0, str(Path(__file__).parent)) + + +def verify_file_exists(filepath: str) -> bool: + """Check if a file exists.""" + path = Path(__file__).parent / filepath + if path.exists(): + print(f"✓ {filepath}") + return True + else: + print(f"✗ {filepath} (MISSING)") + return False + + +def verify_import(module_path: str) -> bool: + """Try to import a module.""" + try: + __import__(module_path) + print(f"✓ {module_path} (importable)") + return True + except Exception as e: + print(f"✗ {module_path} (import failed: {e})") + return False + + +def main(): + """Run verification checks.""" + print("=" * 60) + print("Phase 5: API Routes - File Verification") + print("=" * 60) + + phase5_files = [ + "app/api/__init__.py", + "app/api/deps.py", + "app/api/ssh_keys.py", + "app/api/servers.py", + "app/api/status.py", + ] + + phase5_ok = all(verify_file_exists(f) for f in phase5_files) + + print("\n" + "=" * 60) + print("Phase 6: FastAPI Main Application - File Verification") + print("=" * 60) + + phase6_files = [ + "app/main.py", + ] + + phase6_ok = all(verify_file_exists(f) for f in phase6_files) + + print("\n" + "=" * 60) + print("Test Files - File Verification") + print("=" * 60) + + test_files = [ + "tests/test_api/__init__.py", + "tests/test_api/test_ssh_keys_api.py", + "tests/test_api/test_servers_api.py", + "tests/test_api/test_status_api.py", + ] + + tests_ok = all(verify_file_exists(f) for f in test_files) + + print("\n" + "=" * 60) + print("Import Verification") + print("=" * 60) + + imports_ok = True + imports = [ + "app.api.deps", + "app.api.ssh_keys", + "app.api.servers", + "app.api.status", + ] + + for module in imports: + if not verify_import(module): + imports_ok = False + + print("\n" + "=" * 60) + print("Summary") + print("=" * 60) + + if phase5_ok and phase6_ok and tests_ok: + print("✓ All required files have been created") + else: + print("✗ Some files are missing") + + if imports_ok: + print("✓ All modules can be imported") + else: + print("✗ Some modules failed to import") + + print("\nImplementation Status:") + print(f" Phase 5 (API Routes): {'✓ COMPLETE' if phase5_ok else '✗ INCOMPLETE'}") + print(f" Phase 6 (Main App): {'✓ COMPLETE' if phase6_ok else '✗ INCOMPLETE'}") + print(f" Test Files: {'✓ COMPLETE' if tests_ok else '✗ INCOMPLETE'}") + + +if __name__ == "__main__": + main() diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e8bfb5a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,19 @@ +# Dependencies +node_modules/ +dist/ + +# Logs +*.log +npm-debug.log* + +# Editor +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a42d392 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,57 @@ +# Git Manager Frontend + +Vue 3 frontend for Git Manager application. + +## Tech Stack + +- Vue 3 - Progressive JavaScript framework +- Element Plus - Vue 3 UI component library +- Pinia - State management +- Vue Router - Official router for Vue.js +- Axios - HTTP client +- Vite - Next generation frontend tooling + +## Development + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +## Project Structure + +``` +frontend/ +├── src/ +│ ├── api/ # API client modules +│ ├── assets/ # Static assets +│ ├── components/ # Reusable components +│ ├── router/ # Vue Router configuration +│ ├── stores/ # Pinia stores +│ ├── views/ # Page components +│ ├── App.vue # Root component +│ └── main.js # Application entry point +├── public/ # Public static files +├── index.html # HTML template +├── vite.config.js # Vite configuration +└── package.json # Dependencies +``` + +## API Integration + +The frontend includes API clients for: +- Servers management +- SSH keys management +- Sync logs +- System settings + +All API calls go through the configured Vite proxy to the backend server. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d5fb4d6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Git Manager + + +
+ + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..11ff167 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..c5a27c5 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,39 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' + +const apiClient = axios.create({ + baseURL: '/api', + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// Request interceptor +apiClient.interceptors.request.use( + (config) => { + // Add auth token if available + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => { + return Promise.reject(error) + } +) + +// Response interceptor +apiClient.interceptors.response.use( + (response) => { + return response.data + }, + (error) => { + const message = error.response?.data?.message || error.message || 'An error occurred' + ElMessage.error(message) + return Promise.reject(error) + } +) + +export default apiClient diff --git a/frontend/src/api/servers.js b/frontend/src/api/servers.js new file mode 100644 index 0000000..a8be3a0 --- /dev/null +++ b/frontend/src/api/servers.js @@ -0,0 +1,38 @@ +import api from './index' + +export const serversApi = { + // Get all servers + getAll() { + return api.get('/servers') + }, + + // Get server by ID + getById(id) { + return api.get(`/servers/${id}`) + }, + + // Create new server + create(data) { + return api.post('/servers', data) + }, + + // Update server + update(id, data) { + return api.put(`/servers/${id}`, data) + }, + + // Delete server + delete(id) { + return api.delete(`/servers/${id}`) + }, + + // Test server connection + testConnection(id) { + return api.post(`/servers/${id}/test`) + }, + + // Get server repositories + getRepos(id) { + return api.get(`/servers/${id}/repos`) + } +} diff --git a/frontend/src/api/sshKeys.js b/frontend/src/api/sshKeys.js new file mode 100644 index 0000000..e76ee3b --- /dev/null +++ b/frontend/src/api/sshKeys.js @@ -0,0 +1,33 @@ +import api from './index' + +export const sshKeysApi = { + // Get all SSH keys + getAll() { + return api.get('/ssh-keys') + }, + + // Get SSH key by ID + getById(id) { + return api.get(`/ssh-keys/${id}`) + }, + + // Create new SSH key + create(data) { + return api.post('/ssh-keys', data) + }, + + // Update SSH key + update(id, data) { + return api.put(`/ssh-keys/${id}`, data) + }, + + // Delete SSH key + delete(id) { + return api.delete(`/ssh-keys/${id}`) + }, + + // Generate new SSH key pair + generate() { + return api.post('/ssh-keys/generate') + } +} diff --git a/frontend/src/api/syncLogs.js b/frontend/src/api/syncLogs.js new file mode 100644 index 0000000..455e937 --- /dev/null +++ b/frontend/src/api/syncLogs.js @@ -0,0 +1,33 @@ +import api from './index' + +export const syncLogsApi = { + // Get all sync logs with pagination + getAll(params) { + return api.get('/sync-logs', { params }) + }, + + // Get sync log by ID + getById(id) { + return api.get(`/sync-logs/${id}`) + }, + + // Get logs by server ID + getByServerId(serverId, params) { + return api.get(`/sync-logs/server/${serverId}`, { params }) + }, + + // Get logs by repository ID + getByRepoId(repoId, params) { + return api.get(`/sync-logs/repo/${repoId}`, { params }) + }, + + // Get logs by status + getByStatus(status, params) { + return api.get(`/sync-logs/status/${status}`, { params }) + }, + + // Delete old logs + deleteOld(days) { + return api.delete('/sync-logs/old', { params: { days } }) + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..4d81751 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +// Register Element Plus icons +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(pinia) +app.use(router) +app.use(ElementPlus) + +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..4a89369 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,45 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + redirect: '/dashboard' + }, + { + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue') + }, + { + path: '/servers', + name: 'Servers', + component: () => import('@/views/Servers.vue') + }, + { + path: '/repos', + name: 'Repos', + component: () => import('@/views/Repos.vue') + }, + { + path: '/sync-logs', + name: 'SyncLogs', + component: () => import('@/views/SyncLogs.vue') + }, + { + path: '/ssh-keys', + name: 'SshKeys', + component: () => import('@/views/SshKeys.vue') + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/Settings.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router diff --git a/frontend/src/stores/app.js b/frontend/src/stores/app.js new file mode 100644 index 0000000..80f4d18 --- /dev/null +++ b/frontend/src/stores/app.js @@ -0,0 +1,39 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useAppStore = defineStore('app', () => { + // State + const loading = ref(false) + const sidebarCollapsed = ref(false) + const theme = ref(localStorage.getItem('theme') || 'light') + + // Actions + const setLoading = (value) => { + loading.value = value + } + + const toggleSidebar = () => { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + const setTheme = (newTheme) => { + theme.value = newTheme + localStorage.setItem('theme', newTheme) + document.documentElement.setAttribute('data-theme', newTheme) + } + + // Initialize theme + const initTheme = () => { + document.documentElement.setAttribute('data-theme', theme.value) + } + + return { + loading, + sidebarCollapsed, + theme, + setLoading, + toggleSidebar, + setTheme, + initTheme + } +}) diff --git a/frontend/src/stores/servers.js b/frontend/src/stores/servers.js new file mode 100644 index 0000000..bc8cced --- /dev/null +++ b/frontend/src/stores/servers.js @@ -0,0 +1,146 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { serversApi } from '@/api/servers' + +export const useServersStore = defineStore('servers', () => { + // State + const servers = ref([]) + const currentServer = ref(null) + const loading = ref(false) + const error = ref(null) + + // Actions + const fetchServers = async () => { + loading.value = true + error.value = null + try { + const data = await serversApi.getAll() + servers.value = data + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const fetchServerById = async (id) => { + loading.value = true + error.value = null + try { + const data = await serversApi.getById(id) + currentServer.value = data + return data + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const createServer = async (serverData) => { + loading.value = true + error.value = null + try { + const data = await serversApi.create(serverData) + servers.value.push(data) + return data + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const updateServer = async (id, serverData) => { + loading.value = true + error.value = null + try { + const data = await serversApi.update(id, serverData) + const index = servers.value.findIndex(s => s.id === id) + if (index !== -1) { + servers.value[index] = data + } + if (currentServer.value?.id === id) { + currentServer.value = data + } + return data + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const deleteServer = async (id) => { + loading.value = true + error.value = null + try { + await serversApi.delete(id) + servers.value = servers.value.filter(s => s.id !== id) + if (currentServer.value?.id === id) { + currentServer.value = null + } + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const testConnection = async (id) => { + loading.value = true + error.value = null + try { + const result = await serversApi.testConnection(id) + return result + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const getServerRepos = async (id) => { + loading.value = true + error.value = null + try { + const data = await serversApi.getRepos(id) + return data + } catch (err) { + error.value = err.message + throw err + } finally { + loading.value = false + } + } + + const setCurrentServer = (server) => { + currentServer.value = server + } + + const clearCurrentServer = () => { + currentServer.value = null + } + + return { + servers, + currentServer, + loading, + error, + fetchServers, + fetchServerById, + createServer, + updateServer, + deleteServer, + testConnection, + getServerRepos, + setCurrentServer, + clearCurrentServer + } +}) diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..049b115 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/frontend/src/views/Repos.vue b/frontend/src/views/Repos.vue new file mode 100644 index 0000000..bfff0bb --- /dev/null +++ b/frontend/src/views/Repos.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/views/Servers.vue b/frontend/src/views/Servers.vue new file mode 100644 index 0000000..e5fb308 --- /dev/null +++ b/frontend/src/views/Servers.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..a6303f1 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/frontend/src/views/SshKeys.vue b/frontend/src/views/SshKeys.vue new file mode 100644 index 0000000..1dec54d --- /dev/null +++ b/frontend/src/views/SshKeys.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/frontend/src/views/SyncLogs.vue b/frontend/src/views/SyncLogs.vue new file mode 100644 index 0000000..69ab54a --- /dev/null +++ b/frontend/src/views/SyncLogs.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..208e043 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + } + } + } +}) diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..295c7e6 --- /dev/null +++ b/start.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# +# Git Manager Startup Script +# +# This script checks the environment, initializes the database if needed, +# and starts the uvicorn server. +# + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$SCRIPT_DIR/backend" +ENV_FILE="$SCRIPT_DIR/.env" + +echo "==================================" +echo " Git Manager Startup Script" +echo "==================================" +echo + +# Check if .env file exists +if [ ! -f "$ENV_FILE" ]; then + echo -e "${RED}Error: .env file not found${NC}" + echo + echo "Please create a .env file from the example:" + echo " cp .env.example .env" + echo + echo "Then edit .env and set:" + echo " - GM_ENCRYPT_KEY (generate with: python -c \"import base64, os; print(base64.b64encode(os.urandom(32)).decode())\")" + echo " - GM_API_TOKEN (generate with: python -c \"import secrets; print(secrets.token_urlsafe(32))\")" + exit 1 +fi + +echo -e "${GREEN}✓${NC} Environment configuration found" + +# Check if database exists +DB_PATH="$SCRIPT_DIR/data/git_manager.db" +if [ ! -f "$DB_PATH" ]; then + echo + echo -e "${YELLOW}Database not found. Initializing...${NC}" + cd "$BACKEND_DIR" + python init_db.py + echo -e "${GREEN}✓${NC} Database initialized" +else + echo -e "${GREEN}✓${NC} Database exists" +fi + +# Load environment variables +export $(grep -v '^#' "$ENV_FILE" | xargs) + +# Use defaults if not set in .env +HOST=${GM_HOST:-0.0.0.0} +PORT=${GM_PORT:-8000} + +echo +echo "==================================" +echo " Starting Git Manager" +echo "==================================" +echo "Host: $HOST" +echo "Port: $PORT" +echo "Web UI: http://localhost:$PORT" +echo "API Docs: http://localhost:$PORT/api/docs" +echo + +# Start the server +cd "$BACKEND_DIR" +python -m uvicorn app.main:app --host "$HOST" --port "$PORT" --reload diff --git a/tests/test_schemas.py b/tests/test_schemas.py new file mode 100644 index 0000000..6c9c784 --- /dev/null +++ b/tests/test_schemas.py @@ -0,0 +1,251 @@ +""" +Tests for all Pydantic schemas. +""" +import pytest +from datetime import datetime +from app.schemas.common import SuccessResponse, ErrorResponse +from app.schemas.ssh_key import SshKeyCreate, SshKeyResponse +from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse +from app.schemas.repo import RepoResponse, CommitInfo +from app.schemas.sync_log import SyncLogResponse + + +class TestCommonSchemas: + """Test common response schemas.""" + + def test_success_response_with_data(self): + """Test SuccessResponse with data.""" + response = SuccessResponse(code=0, data={"id": 1}, message="success") + assert response.code == 0 + assert response.data == {"id": 1} + assert response.message == "success" + + def test_success_response_default_values(self): + """Test SuccessResponse with default values.""" + response = SuccessResponse(data={"test": "value"}) + assert response.code == 0 + assert response.message == "success" + + def test_error_response(self): + """Test ErrorResponse.""" + response = ErrorResponse(code=404, message="Not found") + assert response.code == 404 + assert response.message == "Not found" + assert response.data is None + + def test_error_response_with_data(self): + """Test ErrorResponse with additional data.""" + response = ErrorResponse( + code=400, + message="Validation error", + data={"field": "name", "error": "required"} + ) + assert response.code == 400 + assert response.data == {"field": "name", "error": "required"} + + +class TestSshKeySchemas: + """Test SSH key schemas.""" + + def test_ssh_key_create_valid(self): + """Test SshKeyCreate with valid data.""" + key = SshKeyCreate( + name="test-key", + private_key="-----BEGIN OPENSSH PRIVATE KEY-----\ntest\ntest\n-----END OPENSSH PRIVATE KEY-----" + ) + assert key.name == "test-key" + assert "private_key" in key.model_dump() + + def test_ssh_key_create_empty_name_fails(self): + """Test SshKeyCreate fails with empty name.""" + with pytest.raises(ValueError): + SshKeyCreate( + name=" ", + private_key="some-key" + ) + + def test_ssh_key_create_empty_private_key_fails(self): + """Test SshKeyCreate fails with empty private_key.""" + with pytest.raises(ValueError): + SshKeyCreate( + name="test-key", + private_key="" + ) + + def test_ssh_key_response(self): + """Test SshKeyResponse.""" + timestamp = int(datetime.utcnow().timestamp()) + response = SshKeyResponse( + id=1, + name="test-key", + fingerprint="SHA256:abc123", + created_at=timestamp + ) + assert response.id == 1 + assert response.name == "test-key" + assert response.fingerprint == "SHA256:abc123" + assert response.created_at == timestamp + + +class TestServerSchemas: + """Test server schemas.""" + + def test_server_create_valid(self): + """Test ServerCreate with valid data.""" + server = ServerCreate( + name="test-server", + url="https://gitea.example.com", + api_token="test-token", + ssh_key_id=1, + local_path="/data/test" + ) + assert server.name == "test-server" + assert server.sync_enabled is False + assert server.schedule_cron is None + + def test_server_create_with_sync(self): + """Test ServerCreate with sync enabled.""" + server = ServerCreate( + name="test-server", + url="https://gitea.example.com", + api_token="test-token", + ssh_key_id=1, + local_path="/data/test", + sync_enabled=True, + schedule_cron="0 */6 * * *" + ) + assert server.sync_enabled is True + assert server.schedule_cron == "0 */6 * * *" + + def test_server_create_invalid_ssh_key_id(self): + """Test ServerCreate fails with invalid ssh_key_id.""" + with pytest.raises(ValueError): + ServerCreate( + name="test-server", + url="https://gitea.example.com", + api_token="test-token", + ssh_key_id=0, # Must be > 0 + local_path="/data/test" + ) + + def test_server_update_partial(self): + """Test ServerUpdate with partial data.""" + update = ServerUpdate(name="updated-name") + assert update.name == "updated-name" + assert update.url is None + assert update.api_token is None + + def test_server_response(self): + """Test ServerResponse.""" + timestamp = int(datetime.utcnow().timestamp()) + response = ServerResponse( + id=1, + name="test-server", + url="https://gitea.example.com", + ssh_key_id=1, + sync_enabled=True, + schedule_cron="0 */6 * * *", + local_path="/data/test", + status="success", + created_at=timestamp, + updated_at=timestamp + ) + assert response.id == 1 + assert response.status == "success" + assert response.sync_enabled is True + + +class TestRepoSchemas: + """Test repository schemas.""" + + def test_commit_info(self): + """Test CommitInfo schema.""" + timestamp = int(datetime.utcnow().timestamp()) + commit = CommitInfo( + hash="a1b2c3d4", + author="Test Author ", + message="Test commit", + timestamp=timestamp + ) + assert commit.hash == "a1b2c3d4" + assert commit.author == "Test Author " + assert commit.timestamp == timestamp + + def test_repo_response(self): + """Test RepoResponse schema.""" + timestamp = int(datetime.utcnow().timestamp()) + repo = RepoResponse( + id=1, + server_id=1, + name="test-repo", + full_name="org/test-repo", + clone_url="https://gitea.example.com/org/test-repo.git", + local_path="/data/test/org/test-repo", + last_sync_at=timestamp, + status="success", + created_at=timestamp + ) + assert repo.id == 1 + assert repo.name == "test-repo" + assert repo.last_sync_at == timestamp + assert repo.status == "success" + + +class TestSyncLogSchemas: + """Test sync log schemas.""" + + def test_sync_log_response_success(self): + """Test SyncLogResponse for successful sync.""" + timestamp = int(datetime.utcnow().timestamp()) + log = SyncLogResponse( + id=1, + repo_id=1, + status="success", + started_at=timestamp, + finished_at=timestamp + 300, + commits_count=5, + error_msg=None, + created_at=timestamp + ) + assert log.id == 1 + assert log.status == "success" + assert log.commits_count == 5 + assert log.error_msg is None + + def test_sync_log_response_error(self): + """Test SyncLogResponse for failed sync.""" + timestamp = int(datetime.utcnow().timestamp()) + log = SyncLogResponse( + id=2, + repo_id=1, + status="error", + started_at=timestamp, + finished_at=timestamp + 60, + commits_count=None, + error_msg="Connection timeout", + created_at=timestamp + ) + assert log.status == "error" + assert log.commits_count is None + assert log.error_msg == "Connection timeout" + + +class TestSchemaExports: + """Test that all schemas are properly exported.""" + + def test_all_schemas_importable(self): + """Test that all schemas can be imported from app.schemas.""" + from app.schemas import ( + SuccessResponse, + ErrorResponse, + SshKeyCreate, + SshKeyResponse, + ServerCreate, + ServerUpdate, + ServerResponse, + RepoResponse, + CommitInfo, + SyncLogResponse + ) + # If we got here, all imports succeeded + assert True