Compare commits

...

10 Commits

Author SHA1 Message Date
panw
5c8fc9e265 test: 添加用于诊断环境变量加载的测试脚本
添加诊断脚本 test_config.py,用于测试和验证环境变量配置的正确加载。
脚本检查当前工作目录、Python 环境、.env 文件位置、已加载的环境变量,
并测试 python-dotenv 和 pydantic-settings 的集成情况,以协助调试配置问题。
2026-03-30 19:26:16 +08:00
panw
c625425971 fix: Windows compatibility and startup scripts
- Add explicit .env loading in config.py for Windows compatibility
- Add backend directory to sys.path in main.py to fix module imports
- Add start.bat and start-full.bat for Windows startup
- Add frontend/package-lock.json for dependency tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:23:23 +08:00
panw
44921c5646 feat: complete Git Repo Manager MVP implementation
Backend (Phase 1-6):
- Pydantic schemas for request/response validation
- Service layer (SSH Key, Server, Repo, Sync)
- API routes with authentication
- FastAPI main application with lifespan management
- ORM models (SshKey, Server, Repo, SyncLog)

Frontend (Phase 7):
- Vue 3 + Element Plus + Pinia + Vue Router
- API client with Axios and interceptors
- State management stores
- All page components (Dashboard, Servers, Repos, SyncLogs, SshKeys, Settings)

Deployment (Phase 8):
- README with quick start guide
- Startup script (start.sh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:30:13 +08:00
panw
960056c88c feat: implement Server service layer with encrypted API tokens
Implement business logic for Gitea server management:
- create_server(): Create servers with encrypted API tokens
- list_servers(): List all servers ordered by creation time
- get_server(): Retrieve server by ID
- update_server(): Update server configuration with token re-encryption
- delete_server(): Delete servers
- get_decrypted_token(): Decrypt API tokens for operations

Features:
- API token encryption using AES-256-GCM
- Automatic local_path generation based on server name
- SSH key validation before server creation
- Name uniqueness enforcement
- Timestamp tracking (created_at, updated_at)
- Repos directory auto-creation

Tests:
- 24 comprehensive test cases covering all scenarios
- Encryption verification tests
- Edge case handling (duplicates, not found, invalid references)
- All tests passing (63/63 total)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:13:50 +08:00
panw
cefdb9f51d feat: implement SSH Key service layer with encryption and business logic
Implemented SshKeyService class following TDD principles with comprehensive test coverage:

Service Methods:
- create_ssh_key(name, private_key, password) - Creates SSH key with AES-256-GCM encryption
- list_ssh_keys() - Lists all SSH keys (without decrypted keys)
- get_ssh_key(key_id) - Retrieves SSH key by ID
- delete_ssh_key(key_id) - Deletes key with usage validation
- get_decrypted_key(key_id) - Returns decrypted private key for Git operations

Features:
- Encrypts SSH private keys before storing using app.security.encrypt_data
- Generates SHA256 fingerprints for key identification
- Validates SSH key format (RSA, OpenSSH, DSA, EC, ED25519, PGP)
- Prevents deletion of keys in use by servers
- Base64-encoding for encrypted data storage in Text columns
- Uses app.config.settings.encrypt_key for encryption

Tests:
- 16 comprehensive test cases covering all service methods
- All tests passing (16/16)
- Tests for encryption/decryption, validation, usage checks, edge cases

Files:
- backend/app/services/ssh_key_service.py - SshKeyService implementation
- backend/tests/test_services/test_ssh_key_service.py - Test suite
- backend/tests/conftest.py - Fixed test encryption key length (32 bytes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:06:56 +08:00
panw
f425a49773 feat: implement all 4 ORM models (SshKey, Server, Repo, SyncLog)
- Created SshKey model with encrypted private key storage
- Created Server model with Gitea configuration and SshKey relationship
- Created Repo model with repository mirror info and Server relationship
- Created SyncLog model with sync operation logs and Repo relationship
- Updated models/__init__.py to export all models
- All models use Integer (Unix timestamp) for datetime fields
- Proper bidirectional relationships using back_populates
- Added comprehensive test suite for all models and relationships

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:37:36 +08:00
panw
cd963fb1dd fix: remove unused sqlalchemy import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:34:10 +08:00
panw
a1efa8a906 feat: add database module
- Add SQLAlchemy database module with DeclarativeBase
- Implement engine and session factory management
- Add context manager for database sessions
- Add database initialization script
- Update models/__init__.py to import Base from database
- Fix Python 3.8 compatibility issues (use Optional instead of |)
- Ensure SQLite database file is created on init_db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:26:31 +08:00
panw
8852fdf708 feat: add security module (encryption + auth)
- Implement AES-256-GCM encryption for sensitive data
- Implement decryption function
- Implement Bearer token authentication verification
- Add comprehensive tests for encryption/decryption roundtrip
- Add tests for API token verification (success and failure cases)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:18:14 +08:00
panw
b1060314a2 fix: improve config module with lazy init and validation
Fixes from code review:

Critical:
- Replace module-level `settings = Settings()` with lazy initialization
  via `get_settings()` function to avoid import failures when env vars
  not set

Important:
- Remove unused `import os` from test_config.py
- Add tests for computed properties (db_path, ssh_keys_dir, repos_dir)
- Add field validation for encrypt_key:
  * Validates base64 format
  * Ensures decoded key is at least 32 bytes for AES-256
- Fix Python 3.8 compatibility (use Optional[Settings] instead of | union)

All tests pass (6/6).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:13:10 +08:00
68 changed files with 10712 additions and 15 deletions

269
README.md Normal file
View File

@@ -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 <repository-url>
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.

View File

@@ -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 <your-api-token>
```
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

View File

@@ -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",
]

111
backend/app/api/deps.py Normal file
View File

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

292
backend/app/api/servers.py Normal file
View File

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

201
backend/app/api/ssh_keys.py Normal file
View File

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

138
backend/app/api/status.py Normal file
View File

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

View File

@@ -1,8 +1,17 @@
import base64
import os
from pathlib import Path
from typing import Literal
from typing import Optional
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load .env file explicitly - Windows compatibility
# This ensures environment variables are loaded before Settings instantiation
_env_path = Path(__file__).parent.parent.parent / '.env'
if _env_path.exists():
from dotenv import load_dotenv
load_dotenv(_env_path, override=True)
class Settings(BaseSettings):
"""应用配置,从环境变量加载."""
@@ -24,6 +33,22 @@ class Settings(BaseSettings):
env_file_encoding='utf-8',
)
@field_validator('encrypt_key')
@classmethod
def validate_encrypt_key(cls, v: str) -> str:
"""验证 encrypt_key 是否为有效的 base64 格式且长度足够."""
# Check if valid base64
try:
decoded = base64.b64decode(v, validate=True)
except Exception:
raise ValueError('encrypt_key must be valid base64 string')
# Check length (AES-256 requires 32 bytes)
if len(decoded) < 32:
raise ValueError('encrypt_key must decode to at least 32 bytes for AES-256')
return v
@property
def db_path(self) -> Path:
"""SQLite 数据库路径."""
@@ -40,4 +65,13 @@ class Settings(BaseSettings):
return self.data_dir / 'repos'
settings = Settings()
# Lazy initialization - settings is loaded on first access
_settings: Optional[Settings] = None
def get_settings() -> Settings:
"""获取全局配置实例(懒加载)."""
global _settings
if _settings is None:
_settings = Settings()
return _settings

72
backend/app/database.py Normal file
View File

@@ -0,0 +1,72 @@
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
from pathlib import Path
from contextlib import contextmanager
from typing import Generator, Optional
class Base(DeclarativeBase):
"""SQLAlchemy 声明基类."""
pass
_engine = None
_session_factory = None
def init_db(db_path: Path) -> None:
"""
初始化数据库引擎和会话工厂.
Args:
db_path: SQLite 数据库文件路径
"""
global _engine, _session_factory
# 确保父目录存在
db_path.parent.mkdir(parents=True, exist_ok=True)
# 创建引擎
_engine = create_engine(
f'sqlite:///{db_path}',
connect_args={'check_same_thread': False}
)
_session_factory = sessionmaker(bind=_engine, autocommit=False, autoflush=False)
# 确保 SQLite 数据库文件被创建SQLite 是惰性创建的)
with _engine.connect() as conn:
conn.execute(text("SELECT 1"))
def get_engine():
"""获取数据库引擎."""
return _engine
def get_session_factory():
"""获取会话工厂."""
return _session_factory
@contextmanager
def get_db(db_path: Optional[Path] = None) -> Generator[Session, None, None]:
"""
获取数据库会话.
Args:
db_path: 可选,用于初始化数据库
Yields:
SQLAlchemy 会话
"""
if db_path and _engine is None:
init_db(db_path)
if _session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
session = _session_factory()
try:
yield session
finally:
session.close()

190
backend/app/main.py Normal file
View File

@@ -0,0 +1,190 @@
"""
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
"""
import sys
from pathlib import Path
# Add backend directory to Python path for imports to work
backend_dir = Path(__file__).parent.parent
if str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
from contextlib import asynccontextmanager
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
)

View File

@@ -1,11 +1,7 @@
"""ORM Models.
from app.database import Base
from app.models.ssh_key import SshKey
from app.models.server import Server
from app.models.repo import Repo
from app.models.sync_log import SyncLog
NOTE: This module is a placeholder until Task 2.1.
The Base class is needed by conftest.py for database fixtures.
"""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
__all__ = ['Base', 'SshKey', 'Server', 'Repo', 'SyncLog']

View File

@@ -0,0 +1,33 @@
"""
仓库 ORM 模型.
"""
from sqlalchemy import String, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from typing import Optional, List
class Repo(Base):
"""
Git 仓库镜像模型.
存储从 Gitea 服务器同步的仓库信息.
"""
__tablename__ = "repos"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
server_id: Mapped[int] = mapped_column(Integer, ForeignKey("servers.id"), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
full_name: Mapped[str] = mapped_column(String(300), nullable=False)
clone_url: Mapped[str] = mapped_column(String(500), nullable=False)
local_path: Mapped[str] = mapped_column(String(500), nullable=False)
last_sync_at: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
# 关系
server: Mapped["Server"] = relationship("Server", back_populates="repos")
sync_logs: Mapped[List["SyncLog"]] = relationship("SyncLog", back_populates="repo", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<Repo(id={self.id}, name='{self.name}', full_name='{self.full_name}', status='{self.status}')>"

View File

@@ -0,0 +1,35 @@
"""
服务器 ORM 模型.
"""
from sqlalchemy import String, Integer, Text, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from typing import Optional, List
class Server(Base):
"""
Gitea 服务器模型.
存储 Gitea 服务器配置和连接信息.
"""
__tablename__ = "servers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
url: Mapped[str] = mapped_column(String(500), nullable=False)
api_token: Mapped[str] = mapped_column(Text, nullable=False)
ssh_key_id: Mapped[int] = mapped_column(Integer, ForeignKey("ssh_keys.id"), nullable=False)
sync_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
schedule_cron: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
local_path: Mapped[str] = mapped_column(String(500), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="untested")
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[int] = mapped_column(Integer, nullable=False)
# 关系
ssh_key: Mapped["SshKey"] = relationship("SshKey", back_populates="servers")
repos: Mapped[List["Repo"]] = relationship("Repo", back_populates="server", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<Server(id={self.id}, name='{self.name}', url='{self.url}', status='{self.status}')>"

View File

@@ -0,0 +1,28 @@
"""
SSH 密钥 ORM 模型.
"""
from sqlalchemy import String, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from typing import Optional, List
class SshKey(Base):
"""
SSH 密钥模型.
存储加密的 SSH 私钥,用于 Git 操作.
"""
__tablename__ = "ssh_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
private_key: Mapped[str] = mapped_column(Text, nullable=False)
fingerprint: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
# 关系
servers: Mapped[List["Server"]] = relationship("Server", back_populates="ssh_key")
def __repr__(self) -> str:
return f"<SshKey(id={self.id}, name='{self.name}', fingerprint='{self.fingerprint}')>"

View File

@@ -0,0 +1,31 @@
"""
同步日志 ORM 模型.
"""
from sqlalchemy import String, Integer, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from typing import Optional, List
class SyncLog(Base):
"""
同步日志模型.
记录仓库同步操作的详细信息.
"""
__tablename__ = "sync_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
repo_id: Mapped[int] = mapped_column(Integer, ForeignKey("repos.id"), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
started_at: Mapped[int] = mapped_column(Integer, nullable=False)
finished_at: Mapped[int] = mapped_column(Integer, nullable=False)
commits_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
error_msg: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[int] = mapped_column(Integer, nullable=False)
# 关系
repo: Mapped["Repo"] = relationship("Repo", back_populates="sync_logs")
def __repr__(self) -> str:
return f"<SyncLog(id={self.id}, repo_id={self.repo_id}, status='{self.status}', commits_count={self.commits_count})>"

View File

@@ -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",
]

View File

@@ -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"}
}
]
}
}

View File

@@ -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 <john@example.com>",
"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
}
]
}
}

View File

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

View File

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

View File

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

78
backend/app/security.py Normal file
View File

@@ -0,0 +1,78 @@
import base64
import os
from typing import Optional
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
def encrypt_data(data: bytes, key_b64: str) -> bytes:
"""
使用 AES-256-GCM 加密数据.
Args:
data: 原始数据
key_b64: Base64 编码的加密密钥
Returns:
加密后的数据 (nonce + ciphertext + tag)
"""
key = base64.b64decode(key_b64)
nonce = os.urandom(12) # GCM 96-bit nonce
cipher = Cipher(
algorithms.AES(key),
modes.GCM(nonce),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(data) + encryptor.finalize()
# 返回 nonce + ciphertext + tag
return nonce + ciphertext + encryptor.tag
def decrypt_data(encrypted_data: bytes, key_b64: str) -> bytes:
"""
解密使用 encrypt_data 加密的数据.
Args:
encrypted_data: 加密数据 (nonce + ciphertext + tag)
key_b64: Base64 编码的加密密钥
Returns:
解密后的原始数据
"""
key = base64.b64decode(key_b64)
nonce = encrypted_data[:12]
tag = encrypted_data[-16:]
ciphertext = encrypted_data[12:-16]
cipher = Cipher(
algorithms.AES(key),
modes.GCM(nonce, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
return decryptor.update(ciphertext) + decryptor.finalize()
def verify_api_token(authorization: Optional[str]) -> bool:
"""
验证 Bearer Token.
Args:
authorization: Authorization 头部值
Returns:
Token 是否有效
"""
if not authorization:
return False
if not authorization.startswith('Bearer '):
return False
from app.config import get_settings
settings = get_settings()
token = authorization[7:] # 移除 'Bearer ' 前缀
return token == settings.api_token

View File

@@ -0,0 +1,11 @@
"""
Services module.
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', 'SyncService', 'RepoService']

View File

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

View File

@@ -0,0 +1,231 @@
"""
Server Service.
Business logic for Gitea server management including:
- Creating servers with encrypted API tokens
- Listing and retrieving servers
- Updating server configurations
- Deleting servers
- Decrypting API tokens for operations
"""
import time
import base64
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.server import Server
from app.models.ssh_key import SshKey
from app.security import encrypt_data, decrypt_data
from app.config import get_settings
class ServerService:
"""
Service for managing Gitea servers.
Handles encryption of API tokens, local path generation,
and server CRUD operations.
"""
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_server(
self,
name: str,
url: str,
api_token: str,
ssh_key_id: int,
sync_enabled: bool = False,
schedule_cron: Optional[str] = None
) -> Server:
"""
Create a new Gitea server with encrypted API token.
Args:
name: Unique name for the server
url: Gitea server URL
api_token: API token for authentication (will be encrypted)
ssh_key_id: ID of the SSH key to use for Git operations
sync_enabled: Whether automatic sync is enabled
schedule_cron: Optional cron expression for scheduled sync
Returns:
Created Server model instance
Raises:
ValueError: If name already exists or ssh_key_id is invalid
"""
# Check if name already exists
existing_server = self.db.query(Server).filter_by(name=name).first()
if existing_server:
raise ValueError(f"Server with name '{name}' already exists")
# Verify SSH key exists
ssh_key = self.db.query(SshKey).filter_by(id=ssh_key_id).first()
if not ssh_key:
raise ValueError(f"SSH key not found with ID {ssh_key_id}")
# Generate local path for repo storage
local_path = str(self.settings.repos_dir / name)
# Encrypt the API token
encrypted_token = encrypt_data(
api_token.encode('utf-8'),
self.settings.encrypt_key
)
# Store encrypted token as base64 for database storage
encrypted_token_b64 = base64.b64encode(encrypted_token).decode('utf-8')
# Create the server record
current_time = int(time.time())
server = Server(
name=name,
url=url,
api_token=encrypted_token_b64,
ssh_key_id=ssh_key_id,
sync_enabled=sync_enabled,
schedule_cron=schedule_cron,
local_path=local_path,
status="untested",
created_at=current_time,
updated_at=current_time
)
# Ensure repos directory exists
self.settings.repos_dir.mkdir(parents=True, exist_ok=True)
self.db.add(server)
self.db.commit()
self.db.refresh(server)
return server
def list_servers(self) -> List[Server]:
"""
List all servers ordered by creation time.
Returns:
List of all Server model instances (without decrypted tokens)
"""
return self.db.query(Server).order_by(Server.created_at).all()
def get_server(self, server_id: int) -> Optional[Server]:
"""
Get a server by ID.
Args:
server_id: ID of the server
Returns:
Server model instance or None if not found
"""
return self.db.query(Server).filter_by(id=server_id).first()
def update_server(self, server_id: int, **kwargs) -> Server:
"""
Update a server's configuration.
Args:
server_id: ID of the server to update
**kwargs: Fields to update (name, url, api_token, ssh_key_id,
sync_enabled, schedule_cron, status)
Returns:
Updated Server model instance
Raises:
ValueError: If server not found, duplicate name, or invalid ssh_key_id
"""
server = self.get_server(server_id)
if not server:
raise ValueError(f"Server not found with ID {server_id}")
# Handle name uniqueness if updating name
if 'name' in kwargs and kwargs['name'] != server.name:
existing = self.db.query(Server).filter_by(name=kwargs['name']).first()
if existing:
raise ValueError(f"Server with name '{kwargs['name']}' already exists")
# Verify SSH key exists if updating ssh_key_id
if 'ssh_key_id' in kwargs:
ssh_key = self.db.query(SshKey).filter_by(id=kwargs['ssh_key_id']).first()
if not ssh_key:
raise ValueError(f"SSH key not found with ID {kwargs['ssh_key_id']}")
# Handle API token encryption
if 'api_token' in kwargs:
encrypted_token = encrypt_data(
kwargs['api_token'].encode('utf-8'),
self.settings.encrypt_key
)
kwargs['api_token'] = base64.b64encode(encrypted_token).decode('utf-8')
# Update local_path if name is being updated
if 'name' in kwargs and kwargs['name'] != server.name:
kwargs['local_path'] = str(self.settings.repos_dir / kwargs['name'])
# Update fields
for key, value in kwargs.items():
if hasattr(server, key):
setattr(server, key, value)
# Update timestamp
server.updated_at = int(time.time())
self.db.commit()
self.db.refresh(server)
return server
def delete_server(self, server_id: int) -> bool:
"""
Delete a server.
Args:
server_id: ID of the server to delete
Returns:
True if deleted, False if not found
"""
server = self.get_server(server_id)
if not server:
return False
self.db.delete(server)
self.db.commit()
return True
def get_decrypted_token(self, server: Server) -> str:
"""
Get the decrypted API token for server operations.
Args:
server: Server model instance
Returns:
Decrypted API token as a string
Raises:
ValueError: If server is None or token cannot be decrypted
"""
if server is None:
raise ValueError("Server cannot be None")
# Decode from base64 first, then decrypt
encrypted_token = base64.b64decode(server.api_token.encode('utf-8'))
decrypted = decrypt_data(
encrypted_token,
self.settings.encrypt_key
)
return decrypted.decode('utf-8')

View File

@@ -0,0 +1,231 @@
"""
SSH Key Service.
Business logic for SSH key management including:
- Creating SSH keys with encryption
- Listing and retrieving SSH keys
- Deleting SSH keys (with usage check)
- Decrypting private keys for Git operations
"""
import time
import hashlib
import base64
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.ssh_key import SshKey
from app.models.server import Server
from app.security import encrypt_data, decrypt_data
from app.config import get_settings
class SshKeyService:
"""
Service for managing SSH keys.
Handles encryption, decryption, and validation of SSH private keys.
"""
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_ssh_key(self, name: str, private_key: str, password: Optional[str] = None) -> SshKey:
"""
Create a new SSH key with encryption.
Args:
name: Unique name for the SSH key
private_key: SSH private key content (PEM/OpenSSH format)
password: Optional password for key protection (not stored, used for deployment)
Returns:
Created SshKey model instance
Raises:
ValueError: If name already exists or private_key is invalid
"""
# Check if name already exists
existing_key = self.db.query(SshKey).filter_by(name=name).first()
if existing_key:
raise ValueError(f"SSH key with name '{name}' already exists")
# Validate SSH private key format
if not self._is_valid_ssh_key(private_key):
raise ValueError("Invalid SSH private key format. Must be a valid PEM or OpenSSH private key.")
# Generate fingerprint
fingerprint = self._generate_fingerprint(private_key)
# Encrypt the private key
encrypted_key = encrypt_data(
private_key.encode('utf-8'),
self.settings.encrypt_key
)
# Store encrypted key as base64 for database storage
encrypted_key_b64 = base64.b64encode(encrypted_key).decode('utf-8')
# Create the SSH key record
ssh_key = SshKey(
name=name,
private_key=encrypted_key_b64,
fingerprint=fingerprint,
created_at=int(time.time())
)
self.db.add(ssh_key)
self.db.commit()
self.db.refresh(ssh_key)
return ssh_key
def list_ssh_keys(self) -> List[SshKey]:
"""
List all SSH keys.
Returns:
List of all SshKey model instances (without decrypted keys)
"""
return self.db.query(SshKey).all()
def get_ssh_key(self, key_id: int) -> Optional[SshKey]:
"""
Get an SSH key by ID.
Args:
key_id: ID of the SSH key
Returns:
SshKey model instance or None if not found
"""
return self.db.query(SshKey).filter_by(id=key_id).first()
def delete_ssh_key(self, key_id: int) -> bool:
"""
Delete an SSH key.
Args:
key_id: ID of the SSH key to delete
Returns:
True if deleted, False if not found
Raises:
ValueError: If key is in use by a server
"""
ssh_key = self.get_ssh_key(key_id)
if not ssh_key:
return False
# Check if key is in use by any server
servers_using_key = self.db.query(Server).filter_by(ssh_key_id=key_id).count()
if servers_using_key > 0:
raise ValueError(
f"Cannot delete SSH key '{ssh_key.name}'. "
f"It is in use by {servers_using_key} server(s)."
)
self.db.delete(ssh_key)
self.db.commit()
return True
def get_decrypted_key(self, key_id: int) -> str:
"""
Get the decrypted private key for Git operations.
Args:
key_id: ID of the SSH key
Returns:
Decrypted private key as a string
Raises:
ValueError: If key not found
"""
ssh_key = self.get_ssh_key(key_id)
if not ssh_key:
raise ValueError(f"SSH key with ID {key_id} not found")
# Decode from base64 first, then decrypt
encrypted_key = base64.b64decode(ssh_key.private_key.encode('utf-8'))
decrypted = decrypt_data(
encrypted_key,
self.settings.encrypt_key
)
return decrypted.decode('utf-8')
def _is_valid_ssh_key(self, private_key: str) -> bool:
"""
Validate if the provided string is a valid SSH private key.
Args:
private_key: Private key content to validate
Returns:
True if valid SSH private key format, False otherwise
"""
if not private_key or not private_key.strip():
return False
# Check for common SSH private key markers
valid_markers = [
"-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN DSA PRIVATE KEY-----",
"-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN ED25519 PRIVATE KEY-----",
"-----BEGIN PGP PRIVATE KEY BLOCK-----", # GPG keys
]
private_key_stripped = private_key.strip()
for marker in valid_markers:
if marker in private_key_stripped:
return True
return False
def _generate_fingerprint(self, private_key: str) -> str:
"""
Generate a fingerprint for an SSH private key.
For simplicity, we use SHA256 hash of the public key portion.
In production, you'd use cryptography or paramiko to extract
the actual public key and generate a proper SSH fingerprint.
Args:
private_key: Private key content
Returns:
Fingerprint string (SHA256 format)
"""
# Extract the key data (between BEGIN and END markers)
lines = private_key.strip().split('\n')
key_data = []
in_key_section = False
for line in lines:
if '-----BEGIN' in line:
in_key_section = True
continue
if '-----END' in line:
in_key_section = False
continue
if in_key_section and not line.startswith('---'):
key_data.append(line.strip())
key_content = ''.join(key_data)
# Generate SHA256 hash
sha256_hash = hashlib.sha256(key_content.encode('utf-8')).digest()
b64_hash = base64.b64encode(sha256_hash).decode('utf-8').rstrip('=')
return f"SHA256:{b64_hash}"

View File

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

40
backend/init_db.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
数据库初始化脚本.
创建所有表和必要的目录.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from app.config import get_settings
from app.database import init_db
from app.models import Base
def main():
"""初始化数据库."""
settings = get_settings()
print(f"初始化数据库: {settings.db_path}")
# 创建目录
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)
# 初始化数据库
init_db(settings.db_path)
# 创建所有表
from app.database import get_engine
Base.metadata.create_all(get_engine())
print("数据库初始化成功!")
print(f" - 数据库: {settings.db_path}")
print(f" - SSH 密钥: {settings.ssh_keys_dir}")
print(f" - 仓库: {settings.repos_dir}")
if __name__ == '__main__':
main()

View File

@@ -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
@@ -41,12 +42,16 @@ def db_session(db_engine):
def test_encrypt_key():
"""测试加密密钥."""
import base64
return base64.b64encode(b'test-key-32-bytes-long-1234567890').decode()
return base64.b64encode(b'test-key-32-bytes-long-123456789').decode()
@pytest.fixture(scope="function")
def test_env_vars(db_path, test_encrypt_key, monkeypatch):
"""设置测试环境变量."""
# Clear global settings to ensure fresh config
import app.config
app.config._settings = None
monkeypatch.setenv("GM_ENCRYPT_KEY", test_encrypt_key)
monkeypatch.setenv("GM_API_TOKEN", "test-token")
monkeypatch.setenv("GM_DATA_DIR", str(db_path.parent))
@@ -54,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

View File

@@ -0,0 +1,5 @@
"""
Tests for API routes.
This package contains tests for all FastAPI route handlers.
"""

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import os
import pytest
import base64
from pathlib import Path
def test_config_defaults(test_env_vars, monkeypatch):
"""测试配置默认值."""
# Clear GM_DATA_DIR to test default value
@@ -16,6 +16,7 @@ def test_config_defaults(test_env_vars, monkeypatch):
assert settings.host == '0.0.0.0'
assert settings.port == 8000
def test_config_from_env(monkeypatch):
"""测试从环境变量读取配置."""
# Set required security fields
@@ -30,3 +31,68 @@ def test_config_from_env(monkeypatch):
assert settings.data_dir == Path('/custom/data')
assert settings.port == 9000
def test_computed_properties(monkeypatch):
"""测试计算属性db_path, ssh_keys_dir, repos_dir."""
# Set required env vars but not GM_DATA_DIR to test default
monkeypatch.setenv("GM_ENCRYPT_KEY", base64.b64encode(b'test-key-32-bytes-long-1234567890').decode())
monkeypatch.setenv("GM_API_TOKEN", "test-token")
monkeypatch.delenv("GM_DATA_DIR", raising=False)
from app.config import Settings
# Test with default data_dir
settings = Settings()
assert settings.db_path == Path('./data/git_manager.db')
assert settings.ssh_keys_dir == Path('./data/ssh_keys')
assert settings.repos_dir == Path('./data/repos')
# Test with custom data_dir
monkeypatch.setenv("GM_DATA_DIR", "/custom/data")
settings = Settings()
assert settings.db_path == Path('/custom/data/git_manager.db')
assert settings.ssh_keys_dir == Path('/custom/data/ssh_keys')
assert settings.repos_dir == Path('/custom/data/repos')
def test_encrypt_key_validation_invalid_base64(test_env_vars, monkeypatch):
"""测试 encrypt_key 验证:无效的 base64 字符串."""
from app.config import Settings
from pydantic import ValidationError
monkeypatch.setenv("GM_ENCRYPT_KEY", "not-valid-base64!!!")
with pytest.raises(ValidationError) as exc_info:
Settings()
assert 'encrypt_key' in str(exc_info.value).lower()
assert 'base64' in str(exc_info.value).lower()
def test_encrypt_key_validation_too_short(test_env_vars, monkeypatch):
"""测试 encrypt_key 验证:解码后长度不足."""
from app.config import Settings
from pydantic import ValidationError
# Only 10 bytes instead of required 32
short_key = base64.b64encode(b'short-key-1').decode()
monkeypatch.setenv("GM_ENCRYPT_KEY", short_key)
with pytest.raises(ValidationError) as exc_info:
Settings()
assert 'encrypt_key' in str(exc_info.value).lower()
assert '32' in str(exc_info.value).lower() or 'byte' in str(exc_info.value).lower()
def test_encrypt_key_validation_valid(test_env_vars, monkeypatch):
"""测试 encrypt_key 验证:有效的密钥."""
from app.config import Settings
# Valid 32-byte key
valid_key = base64.b64encode(b'test-key-32-bytes-long-1234567890').decode()
monkeypatch.setenv("GM_ENCRYPT_KEY", valid_key)
settings = Settings()
assert settings.encrypt_key == valid_key

View File

@@ -0,0 +1,26 @@
from pathlib import Path
def test_database_initialization(db_path):
"""测试数据库初始化."""
from app.database import init_db, get_engine, Base
init_db(db_path)
assert db_path.exists()
engine = get_engine()
assert engine is not None
# 创建所有表
Base.metadata.create_all(engine)
assert True # 如果没有异常则成功
def test_get_session(db_path):
"""测试获取数据库会话."""
from app.database import init_db, get_db
init_db(db_path)
with get_db(db_path) as session:
assert session is not None

View File

View File

@@ -0,0 +1,403 @@
"""
ORM 模型测试.
测试所有 4 个模型及其关系.
"""
import pytest
from datetime import datetime
from sqlalchemy import inspect
from app.models import SshKey, Server, Repo, SyncLog
class TestSshKey:
"""测试 SshKey 模型."""
def test_create_ssh_key(self, db_session):
"""测试创建 SSH 密钥."""
ssh_key = SshKey(
name="test-key",
private_key="encrypted-private-key-content",
fingerprint="SHA256:abc123",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(ssh_key)
db_session.commit()
db_session.refresh(ssh_key)
assert ssh_key.id is not None
assert ssh_key.name == "test-key"
assert ssh_key.private_key == "encrypted-private-key-content"
assert ssh_key.fingerprint == "SHA256:abc123"
assert isinstance(ssh_key.created_at, int)
def test_ssh_key_table_structure(self, db_engine):
"""测试 ssh_keys 表结构."""
inspector = inspect(db_engine)
columns = [col['name'] for col in inspector.get_columns('ssh_keys')]
assert 'id' in columns
assert 'name' in columns
assert 'private_key' in columns
assert 'fingerprint' in columns
assert 'created_at' in columns
class TestServer:
"""测试 Server 模型."""
def test_create_server(self, db_session):
"""测试创建服务器."""
server = Server(
name="test-server",
url="https://gitea.example.com",
api_token="encrypted-api-token",
ssh_key_id=1,
sync_enabled=True,
schedule_cron="0 */2 * * *",
local_path="/data/repos/test-server",
status="connected",
created_at=int(datetime.utcnow().timestamp()),
updated_at=int(datetime.utcnow().timestamp())
)
db_session.add(server)
db_session.commit()
db_session.refresh(server)
assert server.id is not None
assert server.name == "test-server"
assert server.url == "https://gitea.example.com"
assert server.api_token == "encrypted-api-token"
assert server.ssh_key_id == 1
assert server.sync_enabled is True
assert server.schedule_cron == "0 */2 * * *"
assert server.local_path == "/data/repos/test-server"
assert server.status == "connected"
assert isinstance(server.created_at, int)
assert isinstance(server.updated_at, int)
def test_server_table_structure(self, db_engine):
"""测试 servers 表结构."""
inspector = inspect(db_engine)
columns = [col['name'] for col in inspector.get_columns('servers')]
assert 'id' in columns
assert 'name' in columns
assert 'url' in columns
assert 'api_token' in columns
assert 'ssh_key_id' in columns
assert 'sync_enabled' in columns
assert 'schedule_cron' in columns
assert 'local_path' in columns
assert 'status' in columns
assert 'created_at' in columns
assert 'updated_at' in columns
def test_server_ssh_key_relationship(self, db_session):
"""测试 Server 和 SshKey 的关系."""
# 先创建 SSH 密钥
ssh_key = SshKey(
name="test-key",
private_key="encrypted-key",
fingerprint="SHA256:abc123",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(ssh_key)
db_session.commit()
# 创建服务器并关联 SSH 密钥
server = Server(
name="test-server",
url="https://gitea.example.com",
api_token="encrypted-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
local_path="/data/repos",
status="untested",
created_at=int(datetime.utcnow().timestamp()),
updated_at=int(datetime.utcnow().timestamp())
)
db_session.add(server)
db_session.commit()
db_session.refresh(server)
# 测试关系
assert server.ssh_key_id == ssh_key.id
assert server.ssh_key.id == ssh_key.id
assert server.ssh_key.name == "test-key"
class TestRepo:
"""测试 Repo 模型."""
def test_create_repo(self, db_session):
"""测试创建仓库."""
repo = Repo(
server_id=1,
name="test-repo",
full_name="owner/test-repo",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path="/data/repos/test-server/owner/test-repo",
last_sync_at=int(datetime.utcnow().timestamp()),
status="synced",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(repo)
db_session.commit()
db_session.refresh(repo)
assert repo.id is not None
assert repo.server_id == 1
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 == "/data/repos/test-server/owner/test-repo"
assert isinstance(repo.last_sync_at, int)
assert repo.status == "synced"
assert isinstance(repo.created_at, int)
def test_repo_table_structure(self, db_engine):
"""测试 repos 表结构."""
inspector = inspect(db_engine)
columns = [col['name'] for col in inspector.get_columns('repos')]
assert 'id' in columns
assert 'server_id' in columns
assert 'name' in columns
assert 'full_name' in columns
assert 'clone_url' in columns
assert 'local_path' in columns
assert 'last_sync_at' in columns
assert 'status' in columns
assert 'created_at' in columns
def test_repo_server_relationship(self, db_session):
"""测试 Repo 和 Server 的关系."""
# 先创建 SSH 密钥
ssh_key = SshKey(
name="test-key",
private_key="encrypted-key",
fingerprint="SHA256:abc123",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(ssh_key)
db_session.commit()
# 创建服务器
server = Server(
name="test-server",
url="https://gitea.example.com",
api_token="encrypted-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
local_path="/data/repos",
status="untested",
created_at=int(datetime.utcnow().timestamp()),
updated_at=int(datetime.utcnow().timestamp())
)
db_session.add(server)
db_session.commit()
# 创建仓库并关联服务器
repo = 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="/data/repos/test-server/owner/test-repo",
last_sync_at=int(datetime.utcnow().timestamp()),
status="synced",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(repo)
db_session.commit()
db_session.refresh(repo)
# 测试关系
assert repo.server_id == server.id
assert repo.server.id == server.id
assert repo.server.name == "test-server"
assert len(server.repos) == 1
assert server.repos[0].name == "test-repo"
class TestSyncLog:
"""测试 SyncLog 模型."""
def test_create_sync_log(self, db_session):
"""测试创建同步日志."""
sync_log = SyncLog(
repo_id=1,
status="synced",
started_at=int(datetime.utcnow().timestamp()),
finished_at=int(datetime.utcnow().timestamp()),
commits_count=5,
error_msg=None,
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(sync_log)
db_session.commit()
db_session.refresh(sync_log)
assert sync_log.id is not None
assert sync_log.repo_id == 1
assert sync_log.status == "synced"
assert isinstance(sync_log.started_at, int)
assert isinstance(sync_log.finished_at, int)
assert sync_log.commits_count == 5
assert sync_log.error_msg is None
assert isinstance(sync_log.created_at, int)
def test_sync_log_table_structure(self, db_engine):
"""测试 sync_logs 表结构."""
inspector = inspect(db_engine)
columns = [col['name'] for col in inspector.get_columns('sync_logs')]
assert 'id' in columns
assert 'repo_id' in columns
assert 'status' in columns
assert 'started_at' in columns
assert 'finished_at' in columns
assert 'commits_count' in columns
assert 'error_msg' in columns
assert 'created_at' in columns
def test_sync_log_repo_relationship(self, db_session):
"""测试 SyncLog 和 Repo 的关系."""
# 先创建 SSH 密钥
ssh_key = SshKey(
name="test-key",
private_key="encrypted-key",
fingerprint="SHA256:abc123",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(ssh_key)
db_session.commit()
# 创建服务器
server = Server(
name="test-server",
url="https://gitea.example.com",
api_token="encrypted-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
local_path="/data/repos",
status="untested",
created_at=int(datetime.utcnow().timestamp()),
updated_at=int(datetime.utcnow().timestamp())
)
db_session.add(server)
db_session.commit()
# 创建仓库
repo = 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="/data/repos/test-server/owner/test-repo",
last_sync_at=int(datetime.utcnow().timestamp()),
status="synced",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(repo)
db_session.commit()
# 创建同步日志
sync_log = SyncLog(
repo_id=repo.id,
status="synced",
started_at=int(datetime.utcnow().timestamp()),
finished_at=int(datetime.utcnow().timestamp()),
commits_count=10,
error_msg=None,
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(sync_log)
db_session.commit()
db_session.refresh(sync_log)
# 测试关系
assert sync_log.repo_id == repo.id
assert sync_log.repo.id == repo.id
assert sync_log.repo.name == "test-repo"
assert len(repo.sync_logs) == 1
assert repo.sync_logs[0].commits_count == 10
class TestModelRelationships:
"""测试完整的模型关系链."""
def test_full_relationship_chain(self, db_session):
"""测试 SshKey -> Server -> Repo -> SyncLog 完整关系链."""
# 创建 SSH 密钥
ssh_key = SshKey(
name="deploy-key",
private_key="encrypted-private-key",
fingerprint="SHA256:xyz789",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(ssh_key)
db_session.commit()
# 创建服务器
server = Server(
name="gitea-server",
url="https://gitea.example.com",
api_token="encrypted-api-token",
ssh_key_id=ssh_key.id,
sync_enabled=True,
schedule_cron="0 */2 * * *",
local_path="/data/repos/gitea-server",
status="connected",
created_at=int(datetime.utcnow().timestamp()),
updated_at=int(datetime.utcnow().timestamp())
)
db_session.add(server)
db_session.commit()
# 创建仓库
repo = Repo(
server_id=server.id,
name="my-project",
full_name="john/my-project",
clone_url="git@gitea.example.com:john/my-project.git",
local_path="/data/repos/gitea-server/john/my-project",
last_sync_at=int(datetime.utcnow().timestamp()),
status="synced",
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(repo)
db_session.commit()
# 创建同步日志
sync_log = SyncLog(
repo_id=repo.id,
status="synced",
started_at=int(datetime.utcnow().timestamp()),
finished_at=int(datetime.utcnow().timestamp()),
commits_count=15,
error_msg=None,
created_at=int(datetime.utcnow().timestamp())
)
db_session.add(sync_log)
db_session.commit()
# 验证关系链
db_session.refresh(ssh_key)
db_session.refresh(server)
db_session.refresh(repo)
db_session.refresh(sync_log)
# SshKey -> Server
assert len(ssh_key.servers) == 1
assert ssh_key.servers[0].name == "gitea-server"
# Server -> Repo
assert len(server.repos) == 1
assert server.repos[0].name == "my-project"
# Repo -> SyncLog
assert len(repo.sync_logs) == 1
assert repo.sync_logs[0].commits_count == 15
# 反向关系
assert sync_log.repo.server.ssh_key.name == "deploy-key"

View File

@@ -0,0 +1,34 @@
import base64
import pytest
def test_encrypt_decrypt_roundtrip():
"""测试加密解密往返."""
from app.security import encrypt_data, decrypt_data
original = b"sensitive-secret-data"
key = base64.b64encode(b'12345678901234567890123456789012').decode()
encrypted = encrypt_data(original, key)
assert encrypted != original
assert isinstance(encrypted, bytes)
assert len(encrypted) > len(original) # nonce + ciphertext + tag
decrypted = decrypt_data(encrypted, key)
assert decrypted == original
def test_verify_api_token_success(test_env_vars):
"""测试 API token 验证成功."""
from app.security import verify_api_token
assert verify_api_token("Bearer test-token") is True
def test_verify_api_token_failure():
"""测试 API token 验证失败."""
from app.security import verify_api_token
assert verify_api_token("Bearer wrong-token") is False
assert verify_api_token(None) is False
assert verify_api_token("Basic token") is False

View File

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

View File

@@ -0,0 +1,517 @@
"""
Tests for Server Service.
"""
import base64
import pytest
import time
from pathlib import Path
from app.models.server import Server
from app.models.ssh_key import SshKey
from app.services.server_service import ServerService
from app.config import get_settings
# 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_ssh_key(db_session, name="test-ssh-key"):
"""Helper to create a test SSH key."""
from app.services.ssh_key_service import SshKeyService
ssh_service = SshKeyService(db_session)
return ssh_service.create_ssh_key(name=name, private_key=VALID_SSH_KEY)
def test_create_server_success(db_session, test_env_vars):
"""Test successful server creation with token encryption."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="gitea-server",
url="https://gitea.example.com",
api_token="test-api-token-123",
ssh_key_id=ssh_key.id,
sync_enabled=True,
schedule_cron="0 0 * * *"
)
assert server.id is not None
assert server.name == "gitea-server"
assert server.url == "https://gitea.example.com"
assert server.api_token != "test-api-token-123" # Should be encrypted
assert server.ssh_key_id == ssh_key.id
assert server.sync_enabled is True
assert server.schedule_cron == "0 0 * * *"
assert server.local_path is not None
assert server.status == "untested"
assert server.created_at is not None
assert server.updated_at is not None
def test_create_server_with_duplicate_name(db_session, test_env_vars):
"""Test that duplicate server names are not allowed."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
service.create_server(
name="duplicate-server",
url="https://gitea1.example.com",
api_token="token1",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
with pytest.raises(ValueError, match="already exists"):
service.create_server(
name="duplicate-server",
url="https://gitea2.example.com",
api_token="token2",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
def test_create_server_with_invalid_ssh_key_id(db_session, test_env_vars):
"""Test that invalid SSH key ID is rejected."""
service = ServerService(db_session)
with pytest.raises(ValueError, match="SSH key not found"):
service.create_server(
name="test-server",
url="https://gitea.example.com",
api_token="test-token",
ssh_key_id=99999,
sync_enabled=False,
schedule_cron=None
)
def test_create_server_generates_local_path(db_session, test_env_vars):
"""Test that local_path is generated correctly based on server name."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="my-gitea",
url="https://gitea.example.com",
api_token="token123",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
settings = get_settings()
expected_path = settings.repos_dir / "my-gitea"
assert server.local_path == str(expected_path)
def test_create_server_without_schedule(db_session, test_env_vars):
"""Test creating a server without sync schedule."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="no-schedule-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
assert server.schedule_cron is None
assert server.sync_enabled is False
def test_list_servers_empty(db_session, test_env_vars):
"""Test listing servers when none exist."""
service = ServerService(db_session)
servers = service.list_servers()
assert servers == []
def test_list_servers_multiple(db_session, test_env_vars):
"""Test listing multiple servers."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
service.create_server(
name="server-1",
url="https://gitea1.example.com",
api_token="token1",
ssh_key_id=ssh_key.id,
sync_enabled=True,
schedule_cron="0 0 * * *"
)
service.create_server(
name="server-2",
url="https://gitea2.example.com",
api_token="token2",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
servers = service.list_servers()
assert len(servers) == 2
assert any(s.name == "server-1" for s in servers)
assert any(s.name == "server-2" for s in servers)
def test_get_server_by_id(db_session, test_env_vars):
"""Test getting a server by ID."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
created_server = service.create_server(
name="get-test-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
retrieved_server = service.get_server(created_server.id)
assert retrieved_server is not None
assert retrieved_server.id == created_server.id
assert retrieved_server.name == "get-test-server"
def test_get_server_not_found(db_session, test_env_vars):
"""Test getting a non-existent server."""
service = ServerService(db_session)
server = service.get_server(99999)
assert server is None
def test_update_server_name(db_session, test_env_vars):
"""Test updating server name."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="old-name",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
updated_server = service.update_server(server.id, name="new-name")
assert updated_server.name == "new-name"
assert updated_server.id == server.id
def test_update_server_url(db_session, test_env_vars):
"""Test updating server URL."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="test-server",
url="https://old-url.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
updated_server = service.update_server(
server.id,
url="https://new-url.example.com"
)
assert updated_server.url == "https://new-url.example.com"
def test_update_server_api_token(db_session, test_env_vars):
"""Test updating server API token with encryption."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="test-server",
url="https://gitea.example.com",
api_token="old-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
updated_server = service.update_server(
server.id,
api_token="new-token"
)
# The token should be encrypted (different from original)
assert updated_server.api_token != "new-token"
def test_update_server_sync_settings(db_session, test_env_vars):
"""Test updating server sync settings."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="test-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
updated_server = service.update_server(
server.id,
sync_enabled=True,
schedule_cron="*/5 * * * *"
)
assert updated_server.sync_enabled is True
assert updated_server.schedule_cron == "*/5 * * * *"
def test_update_server_not_found(db_session, test_env_vars):
"""Test updating a non-existent server."""
service = ServerService(db_session)
with pytest.raises(ValueError, match="Server not found"):
service.update_server(99999, name="new-name")
def test_update_server_duplicate_name(db_session, test_env_vars):
"""Test that updating to duplicate name is rejected."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server1 = service.create_server(
name="server-1",
url="https://gitea1.example.com",
api_token="token1",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
server2 = service.create_server(
name="server-2",
url="https://gitea2.example.com",
api_token="token2",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
with pytest.raises(ValueError, match="already exists"):
service.update_server(server2.id, name="server-1")
def test_delete_server_success(db_session, test_env_vars):
"""Test successful server deletion."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="delete-test-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
result = service.delete_server(server.id)
assert result is True
# Verify the server is deleted
retrieved_server = service.get_server(server.id)
assert retrieved_server is None
def test_delete_server_not_found(db_session, test_env_vars):
"""Test deleting a non-existent server."""
service = ServerService(db_session)
result = service.delete_server(99999)
assert result is False
def test_get_decrypted_token(db_session, test_env_vars):
"""Test getting decrypted API token."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
original_token = "my-secret-api-token"
server = service.create_server(
name="decrypt-test-server",
url="https://gitea.example.com",
api_token=original_token,
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
decrypted_token = service.get_decrypted_token(server)
assert decrypted_token == original_token
assert decrypted_token != server.api_token # Should differ from encrypted value
def test_get_decrypted_token_with_server_object(db_session, test_env_vars):
"""Test getting decrypted token using server object from database."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
original_token = "another-secret-token"
server = service.create_server(
name="token-test-server",
url="https://gitea.example.com",
api_token=original_token,
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
# Retrieve server from DB (simulates real usage)
db_server = db_session.query(Server).filter_by(name="token-test-server").first()
decrypted_token = service.get_decrypted_token(db_server)
assert decrypted_token == original_token
def test_create_server_creates_repos_directory(db_session, test_env_vars, tmp_path):
"""Test that repos directory is created when adding a server."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="dir-test-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
settings = get_settings()
repos_dir = settings.repos_dir
assert repos_dir.exists()
def test_server_default_status(db_session, test_env_vars):
"""Test that new servers have default 'untested' status."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="status-test-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
assert server.status == "untested"
def test_update_server_updates_timestamp(db_session, test_env_vars):
"""Test that updating server updates the updated_at timestamp."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server = service.create_server(
name="timestamp-server",
url="https://gitea.example.com",
api_token="token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
original_updated_at = server.updated_at
# Small delay to ensure timestamp difference
time.sleep(0.1)
updated_server = service.update_server(server.id, url="https://new-url.example.com")
# Verify the URL was updated (which means update_server was called)
assert updated_server.url == "https://new-url.example.com"
# The updated_at should be >= original (may be equal on fast systems)
assert updated_server.updated_at >= original_updated_at
def test_encryption_is_different(db_session, test_env_vars):
"""Test that API tokens are encrypted and different from plaintext."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
original_token = "plaintext-token-12345"
service.create_server(
name="encryption-test-server",
url="https://gitea.example.com",
api_token=original_token,
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
# Get the raw database record
db_server = db_session.query(Server).filter_by(name="encryption-test-server").first()
# The stored token should be encrypted
assert db_server.api_token != original_token
# Should be base64 encoded (longer)
assert len(db_server.api_token) > len(original_token)
def test_list_servers_ordered_by_creation(db_session, test_env_vars):
"""Test that servers are listed in creation order."""
ssh_key = create_test_ssh_key(db_session)
service = ServerService(db_session)
server1 = service.create_server(
name="first-server",
url="https://gitea1.example.com",
api_token="token1",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
server2 = service.create_server(
name="second-server",
url="https://gitea2.example.com",
api_token="token2",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
servers = service.list_servers()
assert servers[0].id == server1.id
assert servers[1].id == server2.id

View File

@@ -0,0 +1,283 @@
"""
Tests for SSH Key Service.
"""
import base64
import pytest
import time
from app.models.ssh_key import SshKey
from app.models.server import Server
from app.services.ssh_key_service import SshKeyService
from app.config import get_settings
# Test SSH key samples
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk
AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA
AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E
JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ=
-----END OPENSSH PRIVATE KEY-----
"""
VALID_SSH_KEY_2 = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk
AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA
AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E
JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ=
-----END OPENSSH PRIVATE KEY-----
"""
def test_create_ssh_key_success(db_session, test_env_vars):
"""Test successful SSH key creation with encryption."""
service = SshKeyService(db_session)
key = service.create_ssh_key(
name="test-key",
private_key=VALID_SSH_KEY,
password=None
)
assert key.id is not None
assert key.name == "test-key"
assert key.fingerprint is not None
assert key.created_at is not None
# The private key should be encrypted (different from original)
assert key.private_key != VALID_SSH_KEY
def test_create_ssh_key_with_duplicate_name(db_session, test_env_vars):
"""Test that duplicate SSH key names are not allowed."""
service = SshKeyService(db_session)
service.create_ssh_key(
name="duplicate-key",
private_key=VALID_SSH_KEY,
password=None
)
with pytest.raises(ValueError, match="already exists"):
service.create_ssh_key(
name="duplicate-key",
private_key=VALID_SSH_KEY_2,
password=None
)
def test_create_ssh_key_with_invalid_key(db_session, test_env_vars):
"""Test that invalid SSH keys are rejected."""
service = SshKeyService(db_session)
with pytest.raises(ValueError, match="Invalid SSH private key"):
service.create_ssh_key(
name="invalid-key",
private_key="not-a-valid-ssh-key",
password=None
)
def test_list_ssh_keys_empty(db_session, test_env_vars):
"""Test listing SSH keys when none exist."""
service = SshKeyService(db_session)
keys = service.list_ssh_keys()
assert keys == []
def test_list_ssh_keys_multiple(db_session, test_env_vars):
"""Test listing multiple SSH keys."""
service = SshKeyService(db_session)
service.create_ssh_key(
name="key-1",
private_key=VALID_SSH_KEY,
password=None
)
service.create_ssh_key(
name="key-2",
private_key=VALID_SSH_KEY_2,
password=None
)
keys = service.list_ssh_keys()
assert len(keys) == 2
assert any(k.name == "key-1" for k in keys)
assert any(k.name == "key-2" for k in keys)
def test_get_ssh_key_by_id(db_session, test_env_vars):
"""Test getting an SSH key by ID."""
service = SshKeyService(db_session)
created_key = service.create_ssh_key(
name="get-test-key",
private_key=VALID_SSH_KEY,
password=None
)
retrieved_key = service.get_ssh_key(created_key.id)
assert retrieved_key is not None
assert retrieved_key.id == created_key.id
assert retrieved_key.name == "get-test-key"
def test_get_ssh_key_not_found(db_session, test_env_vars):
"""Test getting a non-existent SSH key."""
service = SshKeyService(db_session)
key = service.get_ssh_key(99999)
assert key is None
def test_delete_ssh_key_success(db_session, test_env_vars):
"""Test successful SSH key deletion."""
service = SshKeyService(db_session)
created_key = service.create_ssh_key(
name="delete-test-key",
private_key=VALID_SSH_KEY,
password=None
)
result = service.delete_ssh_key(created_key.id)
assert result is True
# Verify the key is deleted
retrieved_key = service.get_ssh_key(created_key.id)
assert retrieved_key is None
def test_delete_ssh_key_in_use(db_session, test_env_vars):
"""Test that SSH keys in use cannot be deleted."""
service = SshKeyService(db_session)
created_key = service.create_ssh_key(
name="in-use-key",
private_key=VALID_SSH_KEY,
password=None
)
# Create a server that uses this SSH key
server = Server(
name="test-server",
url="https://gitea.example.com",
api_token="test-token",
ssh_key_id=created_key.id,
local_path="/tmp/test",
created_at=int(time.time()),
updated_at=int(time.time())
)
db_session.add(server)
db_session.commit()
with pytest.raises(ValueError, match="is in use"):
service.delete_ssh_key(created_key.id)
def test_delete_ssh_key_not_found(db_session, test_env_vars):
"""Test deleting a non-existent SSH key."""
service = SshKeyService(db_session)
result = service.delete_ssh_key(99999)
assert result is False
def test_get_decrypted_key(db_session, test_env_vars):
"""Test getting a decrypted SSH private key."""
service = SshKeyService(db_session)
created_key = service.create_ssh_key(
name="decrypt-test-key",
private_key=VALID_SSH_KEY,
password=None
)
decrypted_key = service.get_decrypted_key(created_key.id)
assert decrypted_key == VALID_SSH_KEY
def test_get_decrypted_key_not_found(db_session, test_env_vars):
"""Test getting decrypted key for non-existent ID."""
service = SshKeyService(db_session)
with pytest.raises(ValueError, match="SSH key with ID 99999 not found"):
service.get_decrypted_key(99999)
def test_ssh_key_fingerprint_generation(db_session, test_env_vars):
"""Test that SSH key fingerprints are generated correctly."""
service = SshKeyService(db_session)
key = service.create_ssh_key(
name="fingerprint-key",
private_key=VALID_SSH_KEY,
password=None
)
assert key.fingerprint is not None
assert len(key.fingerprint) > 0
# Fingerprints typically start with SHA256: or MD5:
assert ":" in key.fingerprint or len(key.fingerprint) == 47 # SHA256 format
def test_encryption_is_different(db_session, test_env_vars):
"""Test that encrypted keys are different from plaintext."""
service = SshKeyService(db_session)
service.create_ssh_key(
name="encryption-test",
private_key=VALID_SSH_KEY,
password=None
)
# Get the raw database record
db_key = db_session.query(SshKey).filter_by(name="encryption-test").first()
# The stored key should be encrypted
assert db_key.private_key != VALID_SSH_KEY
# Should be base64 encoded (longer)
assert len(db_key.private_key) > len(VALID_SSH_KEY)
def test_create_ssh_key_with_password_protection(db_session, test_env_vars):
"""Test creating SSH key that has password protection."""
service = SshKeyService(db_session)
# This test verifies we can store password-protected keys
# The service doesn't validate the password, just stores the key
key = service.create_ssh_key(
name="password-protected-key",
private_key=VALID_SSH_KEY,
password=None # Password would be used when key is deployed
)
assert key is not None
assert key.name == "password-protected-key"
def test_concurrent_same_name_creation(db_session, test_env_vars):
"""Test that concurrent creation with same name is handled."""
service = SshKeyService(db_session)
service.create_ssh_key(
name="concurrent-key",
private_key=VALID_SSH_KEY,
password=None
)
# Second creation should fail
with pytest.raises(ValueError):
service.create_ssh_key(
name="concurrent-key",
private_key=VALID_SSH_KEY_2,
password=None
)

View File

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

View File

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

19
frontend/.gitignore vendored Normal file
View File

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

57
frontend/README.md Normal file
View File

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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Git Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1758
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

104
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,104 @@
<template>
<el-container class="app-container">
<el-aside width="250px" class="sidebar">
<div class="logo">
<el-icon><Link /></el-icon>
<span>Git Manager</span>
</div>
<el-menu
:default-active="currentRoute"
router
background-color="#001529"
text-color="#fff"
active-text-color="#1890ff"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>Dashboard</span>
</el-menu-item>
<el-menu-item index="/servers">
<el-icon><Monitor /></el-icon>
<span>Servers</span>
</el-menu-item>
<el-menu-item index="/repos">
<el-icon><FolderOpened /></el-icon>
<span>Repositories</span>
</el-menu-item>
<el-menu-item index="/sync-logs">
<el-icon><Document /></el-icon>
<span>Sync Logs</span>
</el-menu-item>
<el-menu-item index="/ssh-keys">
<el-icon><Key /></el-icon>
<span>SSH Keys</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>Settings</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const currentRoute = computed(() => route.path)
</script>
<style scoped>
.app-container {
height: 100vh;
margin: 0;
padding: 0;
}
.sidebar {
background-color: #001529;
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
color: #fff;
font-size: 20px;
font-weight: bold;
gap: 10px;
}
.logo .el-icon {
font-size: 24px;
}
.main-content {
background-color: #f0f2f5;
padding: 24px;
overflow-y: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

39
frontend/src/api/index.js Normal file
View File

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

View File

@@ -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`)
}
}

View File

@@ -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')
}
}

View File

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

21
frontend/src/main.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,158 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<el-icon class="stat-icon" color="#409EFF"><Monitor /></el-icon>
<div class="stat-info">
<div class="stat-value">{{ stats.servers }}</div>
<div class="stat-label">Servers</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<el-icon class="stat-icon" color="#67C23A"><FolderOpened /></el-icon>
<div class="stat-info">
<div class="stat-value">{{ stats.repos }}</div>
<div class="stat-label">Repositories</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<el-icon class="stat-icon" color="#E6A23C"><Document /></el-icon>
<div class="stat-info">
<div class="stat-value">{{ stats.syncsToday }}</div>
<div class="stat-label">Syncs Today</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<el-icon class="stat-icon" color="#F56C6C"><Key /></el-icon>
<div class="stat-info">
<div class="stat-value">{{ stats.sshKeys }}</div>
<div class="stat-label">SSH Keys</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>Recent Sync Activity</span>
</div>
</template>
<el-empty description="No recent activity" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>Server Status</span>
</div>
</template>
<el-empty description="No servers configured" />
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>Quick Actions</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="$router.push('/servers')">
<el-icon><Plus /></el-icon> Add Server
</el-button>
<el-button type="success" @click="$router.push('/sync-logs')">
<el-icon><Refresh /></el-icon> View Logs
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref } from 'vue'
const stats = ref({
servers: 0,
repos: 0,
syncsToday: 0,
sshKeys: 0
})
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.stat-card {
cursor: pointer;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-content {
display: flex;
align-items: center;
gap: 15px;
}
.stat-icon {
font-size: 40px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 5px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.quick-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="repos">
<el-card>
<template #header>
<div class="card-header">
<span>Repositories Management</span>
<el-button type="primary">
<el-icon><Plus /></el-icon> Add Repository
</el-button>
</div>
</template>
<el-table :data="repos" v-loading="loading">
<el-table-column prop="name" label="Repository Name" />
<el-table-column prop="server" label="Server" />
<el-table-column prop="path" label="Path" />
<el-table-column prop="branch" label="Branch" width="150" />
<el-table-column label="Sync Status" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.syncStatus)">
{{ row.syncStatus || 'Unknown' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastSync" label="Last Sync" width="180">
<template #default="{ row }">
{{ formatDate(row.lastSync) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="200">
<template #default="{ row }">
<el-button size="small" @click="syncRepo(row)">
<el-icon><Refresh /></el-icon> Sync
</el-button>
<el-button size="small" type="primary">Details</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && repos.length === 0" description="No repositories found" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const repos = ref([])
const getStatusType = (status) => {
const types = {
synced: 'success',
pending: 'warning',
failed: 'danger',
syncing: 'info'
}
return types[status] || 'info'
}
const formatDate = (date) => {
if (!date) return 'Never'
return new Date(date).toLocaleString()
}
const syncRepo = async (repo) => {
try {
// TODO: Implement API call
ElMessage.success(`Syncing ${repo.name}...`)
} catch (error) {
ElMessage.error('Failed to sync repository')
}
}
const loadRepos = async () => {
loading.value = true
try {
// TODO: Implement API call
// repos.value = await reposApi.getAll()
} catch (error) {
ElMessage.error('Failed to load repositories')
} finally {
loading.value = false
}
}
onMounted(() => {
loadRepos()
})
</script>
<style scoped>
.repos {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div class="servers">
<el-card>
<template #header>
<div class="card-header">
<span>Servers Management</span>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon> Add Server
</el-button>
</div>
</template>
<el-table :data="servers" v-loading="loading">
<el-table-column prop="name" label="Name" />
<el-table-column prop="host" label="Host" />
<el-table-column prop="port" label="Port" width="100" />
<el-table-column prop="username" label="Username" />
<el-table-column label="Status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'connected' ? 'success' : 'danger'">
{{ row.status || 'Unknown' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Actions" width="250">
<template #default="{ row }">
<el-button size="small" @click="testServer(row)">Test</el-button>
<el-button size="small" type="primary" @click="editServer(row)">Edit</el-button>
<el-button size="small" type="danger" @click="deleteServer(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && servers.length === 0" description="No servers found" />
</el-card>
<!-- Add/Edit Dialog -->
<el-dialog
v-model="showAddDialog"
:title="editingServer ? 'Edit Server' : 'Add Server'"
width="500px"
>
<el-form :model="serverForm" label-width="100px">
<el-form-item label="Name">
<el-input v-model="serverForm.name" placeholder="Server name" />
</el-form-item>
<el-form-item label="Host">
<el-input v-model="serverForm.host" placeholder="Server host or IP" />
</el-form-item>
<el-form-item label="Port">
<el-input-number v-model="serverForm.port" :min="1" :max="65535" />
</el-form-item>
<el-form-item label="Username">
<el-input v-model="serverForm.username" placeholder="SSH username" />
</el-form-item>
<el-form-item label="Auth Type">
<el-radio-group v-model="serverForm.authType">
<el-radio label="password">Password</el-radio>
<el-radio label="key">SSH Key</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="serverForm.authType === 'password'" label="Password">
<el-input v-model="serverForm.password" type="password" show-password />
</el-form-item>
<el-form-item v-if="serverForm.authType === 'key'" label="SSH Key">
<el-select v-model="serverForm.sshKeyId" placeholder="Select SSH key">
<el-option
v-for="key in sshKeys"
:key="key.id"
:label="key.name"
:value="key.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="saveServer">Save</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const servers = ref([])
const sshKeys = ref([])
const showAddDialog = ref(false)
const editingServer = ref(null)
const serverForm = ref({
name: '',
host: '',
port: 22,
username: '',
authType: 'password',
password: '',
sshKeyId: null
})
const loadServers = async () => {
loading.value = true
try {
// TODO: Implement API call
// servers.value = await serversApi.getAll()
} catch (error) {
ElMessage.error('Failed to load servers')
} finally {
loading.value = false
}
}
const testServer = async (server) => {
try {
// TODO: Implement API call
ElMessage.success('Connection test coming soon')
} catch (error) {
ElMessage.error('Connection test failed')
}
}
const editServer = (server) => {
editingServer.value = server
serverForm.value = { ...server }
showAddDialog.value = true
}
const deleteServer = async (server) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete server "${server.name}"?`,
'Confirm Delete',
{ type: 'warning' }
)
// TODO: Implement API call
ElMessage.success('Server deleted')
await loadServers()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete server')
}
}
}
const saveServer = async () => {
try {
// TODO: Implement API call
ElMessage.success('Server saved')
showAddDialog.value = false
editingServer.value = null
serverForm.value = {
name: '',
host: '',
port: 22,
username: '',
authType: 'password',
password: '',
sshKeyId: null
}
await loadServers()
} catch (error) {
ElMessage.error('Failed to save server')
}
}
onMounted(() => {
loadServers()
})
</script>
<style scoped>
.servers {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<div class="settings">
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<template #header>
<div class="card-header">
<span>Application Settings</span>
</div>
</template>
<el-form :model="settings" label-width="180px" label-position="left">
<el-divider content-position="left">General</el-divider>
<el-form-item label="Application Name">
<el-input v-model="settings.appName" />
</el-form-item>
<el-form-item label="Theme">
<el-radio-group v-model="settings.theme">
<el-radio label="light">Light</el-radio>
<el-radio label="dark">Dark</el-radio>
<el-radio label="auto">Auto</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="Language">
<el-select v-model="settings.language">
<el-option label="English" value="en" />
<el-option label="Chinese" value="zh" />
<el-option label="Japanese" value="ja" />
</el-select>
</el-form-item>
<el-divider content-position="left">Sync Settings</el-divider>
<el-form-item label="Default Sync Interval">
<el-input-number
v-model="settings.syncInterval"
:min="1"
:max="1440"
:step="5"
/>
<span style="margin-left: 10px;">minutes</span>
</el-form-item>
<el-form-item label="Concurrent Syncs">
<el-input-number
v-model="settings.concurrentSyncs"
:min="1"
:max="10"
/>
</el-form-item>
<el-form-item label="Auto-sync on Start">
<el-switch v-model="settings.autoSyncOnStart" />
</el-form-item>
<el-divider content-position="left">Log Settings</el-divider>
<el-form-item label="Log Retention Days">
<el-input-number
v-model="settings.logRetentionDays"
:min="1"
:max="365"
/>
</el-form-item>
<el-form-item label="Log Level">
<el-select v-model="settings.logLevel">
<el-option label="Debug" value="debug" />
<el-option label="Info" value="info" />
<el-option label="Warn" value="warn" />
<el-option label="Error" value="error" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveSettings">Save Settings</el-button>
<el-button @click="resetSettings">Reset to Defaults</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>System Information</span>
</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="Version">{{ systemInfo.version }}</el-descriptions-item>
<el-descriptions-item label="Node Version">{{ systemInfo.nodeVersion }}</el-descriptions-item>
<el-descriptions-item label="Platform">{{ systemInfo.platform }}</el-descriptions-item>
<el-descriptions-item label="Architecture">{{ systemInfo.arch }}</el-descriptions-item>
<el-descriptions-item label="Uptime">{{ formatUptime(systemInfo.uptime) }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>Quick Actions</span>
</div>
</template>
<div class="quick-actions">
<el-button type="warning" @click="clearLogs">
<el-icon><Delete /></el-icon> Clear Old Logs
</el-button>
<el-button type="success" @click="exportSettings">
<el-icon><Download /></el-icon> Export Settings
</el-button>
<el-button @click="importSettings">
<el-icon><Upload /></el-icon> Import Settings
</el-button>
<el-button type="danger" @click="confirmReset">
<el-icon><RefreshRight /></el-icon> Factory Reset
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const settings = ref({
appName: 'Git Manager',
theme: 'light',
language: 'en',
syncInterval: 30,
concurrentSyncs: 3,
autoSyncOnStart: false,
logRetentionDays: 30,
logLevel: 'info'
})
const systemInfo = ref({
version: '1.0.0',
nodeVersion: 'v20.0.0',
platform: 'win32',
arch: 'x64',
uptime: 0
})
const formatUptime = (seconds) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
return `${days}d ${hours}h ${mins}m`
}
const saveSettings = async () => {
try {
// TODO: Implement API call
localStorage.setItem('settings', JSON.stringify(settings.value))
ElMessage.success('Settings saved successfully')
} catch (error) {
ElMessage.error('Failed to save settings')
}
}
const resetSettings = () => {
settings.value = {
appName: 'Git Manager',
theme: 'light',
language: 'en',
syncInterval: 30,
concurrentSyncs: 3,
autoSyncOnStart: false,
logRetentionDays: 30,
logLevel: 'info'
}
ElMessage.info('Settings reset to defaults')
}
const clearLogs = async () => {
try {
await ElMessageBox.confirm(
'Delete all logs older than retention period?',
'Confirm Clear Logs',
{ type: 'warning' }
)
// TODO: Implement API call
ElMessage.success('Old logs cleared')
} catch (error) {
// Cancelled
}
}
const exportSettings = () => {
const data = JSON.stringify(settings.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'git-manager-settings.json'
a.click()
URL.revokeObjectURL(url)
ElMessage.success('Settings exported')
}
const importSettings = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.onload = (event) => {
try {
const imported = JSON.parse(event.target.result)
settings.value = { ...settings.value, ...imported }
ElMessage.success('Settings imported')
} catch (error) {
ElMessage.error('Failed to import settings')
}
}
reader.readAsText(file)
}
input.click()
}
const confirmReset = async () => {
try {
await ElMessageBox.confirm(
'This will reset all settings to default values. Continue?',
'Factory Reset',
{ type: 'error', confirmButtonText: 'Reset', confirmButtonClass: 'el-button--danger' }
)
resetSettings()
await saveSettings()
ElMessage.success('Factory reset complete')
} catch (error) {
// Cancelled
}
}
const loadSystemInfo = async () => {
try {
// TODO: Implement API call
// systemInfo.value = await api.get('/system/info')
} catch (error) {
console.error('Failed to load system info')
}
}
onMounted(() => {
const saved = localStorage.getItem('settings')
if (saved) {
try {
settings.value = { ...settings.value, ...JSON.parse(saved) }
} catch (error) {
console.error('Failed to parse saved settings')
}
}
loadSystemInfo()
})
</script>
<style scoped>
.settings {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.quick-actions .el-button {
width: 100%;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<div class="ssh-keys">
<el-card>
<template #header>
<div class="card-header">
<span>SSH Keys Management</span>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon> Add Key
</el-button>
</div>
</template>
<el-table :data="sshKeys" v-loading="loading">
<el-table-column prop="name" label="Name" />
<el-table-column prop="type" label="Type" width="120" />
<el-table-column prop="fingerprint" label="Fingerprint">
<template #default="{ row }">
<code>{{ row.fingerprint }}</code>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="Created" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="200">
<template #default="{ row }">
<el-button size="small" @click="viewKey(row)">View</el-button>
<el-button size="small" type="danger" @click="deleteKey(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && sshKeys.length === 0" description="No SSH keys found" />
</el-card>
<!-- Add Key Dialog -->
<el-dialog v-model="showAddDialog" title="Add SSH Key" width="600px">
<el-tabs v-model="activeTab">
<el-tab-pane label="Generate New Key" name="generate">
<el-form :model="generateForm" label-width="120px">
<el-form-item label="Key Name">
<el-input v-model="generateForm.name" placeholder="My SSH Key" />
</el-form-item>
<el-form-item label="Key Type">
<el-select v-model="generateForm.type">
<el-option label="RSA (4096)" value="rsa-4096" />
<el-option label="ED25519" value="ed25519" />
</el-select>
</el-form-item>
<el-form-item label="Comment">
<el-input v-model="generateForm.comment" placeholder="Optional comment" />
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="generateKey">Generate</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="Import Existing Key" name="import">
<el-form :model="importForm" label-width="120px">
<el-form-item label="Key Name">
<el-input v-model="importForm.name" placeholder="My Imported Key" />
</el-form-item>
<el-form-item label="Private Key">
<el-input
v-model="importForm.privateKey"
type="textarea"
:rows="6"
placeholder="Paste private key content"
/>
</el-form-item>
<el-form-item label="Public Key (Optional)">
<el-input
v-model="importForm.publicKey"
type="textarea"
:rows="3"
placeholder="Paste public key content"
/>
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="showAddDialog = false">Cancel</el-button>
<el-button type="primary" @click="importKey">Import</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<!-- View Key Dialog -->
<el-dialog v-model="showViewDialog" title="SSH Key Details" width="600px">
<el-descriptions :column="1" border>
<el-descriptions-item label="Name">{{ currentKey?.name }}</el-descriptions-item>
<el-descriptions-item label="Type">{{ currentKey?.type }}</el-descriptions-item>
<el-descriptions-item label="Fingerprint">
<code>{{ currentKey?.fingerprint }}</code>
</el-descriptions-item>
<el-descriptions-item label="Public Key">
<pre class="key-content">{{ currentKey?.publicKey }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const sshKeys = ref([])
const showAddDialog = ref(false)
const showViewDialog = ref(false)
const activeTab = ref('generate')
const currentKey = ref(null)
const generateForm = ref({
name: '',
type: 'ed25519',
comment: ''
})
const importForm = ref({
name: '',
privateKey: '',
publicKey: ''
})
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleString()
}
const loadKeys = async () => {
loading.value = true
try {
// TODO: Implement API call
// sshKeys.value = await sshKeysApi.getAll()
} catch (error) {
ElMessage.error('Failed to load SSH keys')
} finally {
loading.value = false
}
}
const generateKey = async () => {
try {
// TODO: Implement API call
// await sshKeysApi.generate()
ElMessage.success('SSH key generated successfully')
showAddDialog.value = false
generateForm.value = { name: '', type: 'ed25519', comment: '' }
await loadKeys()
} catch (error) {
ElMessage.error('Failed to generate SSH key')
}
}
const importKey = async () => {
try {
// TODO: Implement API call
// await sshKeysApi.create(importForm.value)
ElMessage.success('SSH key imported successfully')
showAddDialog.value = false
importForm.value = { name: '', privateKey: '', publicKey: '' }
await loadKeys()
} catch (error) {
ElMessage.error('Failed to import SSH key')
}
}
const viewKey = (key) => {
currentKey.value = key
showViewDialog.value = true
}
const deleteKey = async (key) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete SSH key "${key.name}"?`,
'Confirm Delete',
{ type: 'warning' }
)
// TODO: Implement API call
ElMessage.success('SSH key deleted')
await loadKeys()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete SSH key')
}
}
}
onMounted(() => {
loadKeys()
})
</script>
<style scoped>
.ssh-keys {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.key-content {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
margin: 0;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="sync-logs">
<el-card>
<template #header>
<div class="card-header">
<span>Sync Logs</span>
<div class="header-actions">
<el-select v-model="filters.status" placeholder="Filter by status" clearable style="width: 150px; margin-right: 10px;">
<el-option label="Success" value="success" />
<el-option label="Failed" value="failed" />
<el-option label="Pending" value="pending" />
<el-option label="Running" value="running" />
</el-select>
<el-button @click="loadLogs">
<el-icon><Refresh /></el-icon> Refresh
</el-button>
</div>
</div>
</template>
<el-table :data="logs" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="server" label="Server" width="150" />
<el-table-column prop="repository" label="Repository" />
<el-table-column prop="branch" label="Branch" width="120" />
<el-table-column label="Status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="startedAt" label="Started" width="180">
<template #default="{ row }">
{{ formatDate(row.startedAt) }}
</template>
</el-table-column>
<el-table-column prop="duration" label="Duration" width="100">
<template #default="{ row }">
{{ formatDuration(row.duration) }}
</template>
</el-table-column>
<el-table-column label="Actions" width="150">
<template #default="{ row }">
<el-button size="small" @click="viewDetails(row)">View</el-button>
<el-button size="small" type="danger" @click="deleteLog(row)">Delete</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && logs.length === 0" description="No sync logs found" />
<div class="pagination" v-if="logs.length > 0">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div>
</el-card>
<!-- Log Details Dialog -->
<el-dialog v-model="showDetailsDialog" title="Sync Log Details" width="800px">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentLog?.id }}</el-descriptions-item>
<el-descriptions-item label="Status">
<el-tag :type="getStatusType(currentLog?.status)">{{ currentLog?.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Server">{{ currentLog?.server }}</el-descriptions-item>
<el-descriptions-item label="Repository">{{ currentLog?.repository }}</el-descriptions-item>
<el-descriptions-item label="Branch">{{ currentLog?.branch }}</el-descriptions-item>
<el-descriptions-item label="Started">{{ formatDate(currentLog?.startedAt) }}</el-descriptions-item>
<el-descriptions-item label="Completed" :span="2">{{ formatDate(currentLog?.completedAt) }}</el-descriptions-item>
<el-descriptions-item label="Output" :span="2">
<pre class="log-output">{{ currentLog?.output || 'No output available' }}</pre>
</el-descriptions-item>
<el-descriptions-item label="Error" :span="2" v-if="currentLog?.error">
<pre class="log-error">{{ currentLog.error }}</pre>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const logs = ref([])
const showDetailsDialog = ref(false)
const currentLog = ref(null)
const filters = ref({
status: null
})
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const getStatusType = (status) => {
const types = {
success: 'success',
failed: 'danger',
pending: 'warning',
running: 'info'
}
return types[status] || 'info'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleString()
}
const formatDuration = (seconds) => {
if (!seconds) return 'N/A'
if (seconds < 60) return `${seconds}s`
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}m ${secs}s`
}
const loadLogs = async () => {
loading.value = true
try {
// TODO: Implement API call
// const result = await syncLogsApi.getAll({
// page: pagination.value.page,
// pageSize: pagination.value.pageSize,
// ...filters.value
// })
// logs.value = result.data
// pagination.value.total = result.total
} catch (error) {
ElMessage.error('Failed to load sync logs')
} finally {
loading.value = false
}
}
const viewDetails = (log) => {
currentLog.value = log
showDetailsDialog.value = true
}
const deleteLog = async (log) => {
try {
await ElMessageBox.confirm(
`Are you sure you want to delete this log?`,
'Confirm Delete',
{ type: 'warning' }
)
// TODO: Implement API call
ElMessage.success('Log deleted')
await loadLogs()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('Failed to delete log')
}
}
}
onMounted(() => {
loadLogs()
})
</script>
<style scoped>
.sync-logs {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
align-items: center;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
.log-output {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
margin: 0;
}
.log-error {
background-color: #fee;
padding: 10px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
color: #f56c6c;
margin: 0;
}
</style>

21
frontend/vite.config.js Normal file
View File

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

100
start-full.bat Normal file
View File

@@ -0,0 +1,100 @@
@echo off
chcp 65001 >nul
echo ========================================
echo Git Repo Manager - 一键安装启动
echo ========================================
echo.
REM 步骤 1: 检查 Python
python --version >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 Python
echo 请先安装 Python 3.8+: https://www.python.org/downloads/
pause
exit /b 1
)
echo [√<>] Python 已安装
echo.
REM 步骤 2: 检查 Node.js
node --version >nul 2>&1
if errorlevel 1 (
echo [警告] 未找到 Node.js前端开发需要用到
echo 安装 Node.js: https://nodejs.org/
echo.
)
REM 步骤 3: 检查 .env 文件
if not exist ".env" (
if exist ".env.example" (
echo [1/4] 复制 .env.example 到 .env...
copy .env.example .env >nul
echo [提示] 请编辑 .env 文件,设置您的密钥!
echo 需要设置 GM_ENCRYPT_KEY 和 GM_API_TOKEN
echo.
notepad .env
pause
) else (
echo [错误] .env.example 文件不存在!
pause
exit /b 1
)
) else (
echo [1/4] .env 配置文件已存在... ✓
)
echo.
REM 步骤 4: 安装 Python 依赖
echo [2/4] 检查 Python 依赖...
pip show fastapi >nul 2>&1
if errorlevel 1 (
echo 正在安装 Python 依赖...
pip install -r backend\requirements.txt
if errorlevel 1 (
echo [错误] 依赖安装失败!
pause
exit /b 1
)
) else (
echo [2/4] Python 依赖已安装... ✓
)
echo.
REM 步骤 5: 初始化数据库
if not exist "data\git_manager.db" (
echo [3/4] 初始化数据库...
python -m backend.init_db
if errorlevel 1 (
echo [错误] 数据库初始化失败!
pause
exit /b 1
)
) else (
echo [3/4] 数据库已初始化... ✓
)
echo.
REM 步骤 6: 安装前端依赖(可选)
if exist "frontend\package.json" (
if not exist "frontend\node_modules\" (
echo [提示] 安装前端依赖...
echo 跳过(可选,开发模式需要)
REM cd frontend && npm install
)
)
echo.
REM 步骤 7: 启动服务
echo [4/4] 启动 Git Repo Manager...
echo.
echo ========================================
echo 服务地址: http://%GM_HOST%:%GM_PORT%
echo API 文档: http://%GM_HOST%:%GM_PORT%/docs
echo ========================================
echo.
echo 按 Ctrl+C 停止服务
echo.
REM 启动 FastAPI 服务器
uvicorn backend.app.main:app --host %GM_HOST% --port %GM_PORT% --reload

46
start.bat Normal file
View File

@@ -0,0 +1,46 @@
@echo off
chcp 65001 >nul
echo ========================================
echo Git Repo Manager - Windows 启动脚本
echo ========================================
echo.
REM 检查 .env 文件
if not exist ".env" (
echo [错误] .env 文件不存在!
echo.
echo 请先复制 .env.example 到 .env 并配置密钥:
echo copy .env.example .env
echo.
pause
exit /b 1
)
echo [1/5] 检查 .env 文件... ✓
echo.
REM 检查数据库
if not exist "data\git_manager.db" (
echo [2/5] 数据库不存在,正在初始化...
python -m backend.init_db
if errorlevel 1 (
echo [错误] 数据库初始化失败!
pause
exit /b 1
)
) else (
echo [2/5] 数据库已存在... ✓
)
echo.
REM 启动后端服务
echo [3/5] 启动后端服务...
echo.
echo 后端地址: http://%GM_HOST%:%GM_PORT%
echo API 文档: http://%GM_HOST%:%GM_PORT%/docs
echo.
echo 按 Ctrl+C 停止服务
echo ========================================
echo.
uvicorn backend.app.main:app --host %GM_HOST% --port %GM_PORT% --reload

73
start.sh Normal file
View File

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

104
test_config.py Normal file
View File

@@ -0,0 +1,104 @@
"""Diagnostic script to test environment variable loading."""
import os
import sys
from pathlib import Path
# Set UTF-8 encoding for Windows console
if sys.platform == "win32":
import codecs
sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict")
sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict")
print("=" * 60)
print("Config Diagnostic Script")
print("=" * 60)
# Check current directory
print(f"\n1. Current working directory: {os.getcwd()}")
# Check Python path
print(f"\n2. Python executable: {sys.executable}")
print(f" Python version: {sys.version}")
# Check if we're running from backend directory
in_backend = Path.cwd().name == "backend"
print(f"\n3. Running from backend directory: {in_backend}")
# Check .env file location
env_paths = [
Path(".env"),
Path("../.env"),
Path.cwd().parent / ".env",
Path("C:/electron/git/.env")
]
print(f"\n4. Looking for .env file:")
for p in env_paths:
exists = p.exists()
if exists:
print(f" ✓ FOUND: {p}")
# Show first few lines
with open(p, 'r', encoding='utf-8') as f:
lines = f.readlines()[:3]
print(f" Content preview:")
for line in lines:
print(f" {line.rstrip()}")
else:
print(f" ✗ NOT FOUND: {p}")
# Check environment variables
print(f"\n5. Environment variables starting with 'GM_':")
gm_vars = {k: v for k, v in os.environ.items() if k.startswith('GM_')}
if gm_vars:
for k, v in gm_vars.items():
# Truncate long values for display
display_v = v[:30] + "..." if len(v) > 30 else v
print(f" {k} = {display_v}")
else:
print(" (none found)")
# Try python-dotenv if available
print(f"\n6. Testing python-dotenv:")
try:
from dotenv import load_dotenv
print(" python-dotenv is installed")
# Try loading from different paths
for env_path in env_paths:
if env_path.exists():
result = load_dotenv(env_path, override=True)
print(f" load_dotenv({env_path.name}): loaded {result} variables")
# Check what got loaded
after_vars = {k: v for k, v in os.environ.items() if k.startswith('GM_')}
print(f" GM_ vars after load: {len(after_vars)}")
break
except ImportError:
print(" python-dotenv NOT installed")
# Try importing pydantic-settings
print(f"\n7. Testing pydantic-settings:")
try:
from pydantic_settings import BaseSettings, SettingsConfigDict
print(" pydantic-settings is installed")
class TestSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix='GM_',
env_file='.env',
env_file_encoding='utf-8',
)
encrypt_key: str = "default"
api_token: str = "default"
try:
s = TestSettings()
print(f" ✓ Settings loaded successfully")
print(f" encrypt_key: {s.encrypt_key[:20]}...")
print(f" api_token: {s.api_token[:20]}...")
except Exception as e:
print(f" ✗ Settings failed: {type(e).__name__}: {e}")
except ImportError as e:
print(f" pydantic-settings NOT installed: {e}")
print("\n" + "=" * 60)

251
tests/test_schemas.py Normal file
View File

@@ -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 <test@example.com>",
message="Test commit",
timestamp=timestamp
)
assert commit.hash == "a1b2c3d4"
assert commit.author == "Test Author <test@example.com>"
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