Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8431920d26 | |||
| 99b626eadc | |||
| 0c45efc0ca | |||
| 59da4eb9f9 | |||
| 24cb173caf | |||
| 4eae0dca2f | |||
| 69a198f723 | |||
| b742ab8fef | |||
| d19707422d | |||
| 427decc83a | |||
| 9929567679 | |||
| d0078f7e9d | |||
| d69b744ae7 | |||
| 7b17a53cab | |||
| 00b5d362e7 | |||
| 0d251a09be | |||
| e0f432bc39 | |||
| 51ad3bfe5b | |||
| 74100c8882 | |||
| 5c5baba3d4 | |||
| 7277560856 | |||
| b98333db36 | |||
| 42c5693ede | |||
| 2f8a5d84dc | |||
| 75cfafd388 | |||
| 80ad5e9197 | |||
| cc3e9dc6fa | |||
| 0d50958134 | |||
| 4201917e17 | |||
| 1e5b32060e | |||
| e72665c466 | |||
| b580995966 | |||
| c162164528 | |||
| 73e4feb2c3 | |||
| 175ebeede4 | |||
| c8255dccec |
@@ -44,7 +44,7 @@ jobs:
|
|||||||
image_ref="$1"
|
image_ref="$1"
|
||||||
mirror_ref="${mirror_host}/library/${image_ref}"
|
mirror_ref="${mirror_host}/library/${image_ref}"
|
||||||
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
||||||
if docker pull "${mirror_ref}"; then
|
if timeout 60 docker pull "${mirror_ref}"; then
|
||||||
docker tag "${mirror_ref}" "${image_ref}"
|
docker tag "${mirror_ref}" "${image_ref}"
|
||||||
echo "Mirror pre-pull succeeded for ${image_ref}"
|
echo "Mirror pre-pull succeeded for ${image_ref}"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
image_ref="$1"
|
image_ref="$1"
|
||||||
mirror_ref="${mirror_host}/library/${image_ref}"
|
mirror_ref="${mirror_host}/library/${image_ref}"
|
||||||
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
echo "Attempting mirror pre-pull: ${mirror_ref}"
|
||||||
if docker pull "${mirror_ref}"; then
|
if timeout 60 docker pull "${mirror_ref}"; then
|
||||||
docker tag "${mirror_ref}" "${image_ref}"
|
docker tag "${mirror_ref}" "${image_ref}"
|
||||||
echo "Mirror pre-pull succeeded for ${image_ref}"
|
echo "Mirror pre-pull succeeded for ${image_ref}"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
name: Run Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend-tests:
|
||||||
|
name: Backend Tests
|
||||||
|
runs-on: nas-runner
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: dashboard/backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
pytest tests/unit/ -v --cov=. --cov-report=term --cov-report=html --cov-report=xml
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: |
|
||||||
|
pytest tests/integration/ -v --cov=. --cov-append --cov-report=term --cov-report=html --cov-report=xml
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Upload backend coverage report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: dashboard/backend/htmlcov/
|
||||||
|
|
||||||
|
- name: Upload backend coverage XML
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend-coverage-xml
|
||||||
|
path: dashboard/backend/coverage.xml
|
||||||
|
|
||||||
|
frontend-tests:
|
||||||
|
name: Frontend Tests
|
||||||
|
runs-on: nas-runner
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: dashboard/frontend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: dashboard/frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests with coverage
|
||||||
|
run: npm run test:coverage
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Upload frontend coverage report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: dashboard/frontend/coverage/
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
name: Test Summary
|
||||||
|
runs-on: nas-runner
|
||||||
|
needs: [backend-tests, frontend-tests]
|
||||||
|
if: always()
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download backend coverage
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend-coverage-xml
|
||||||
|
path: ./backend-coverage
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Download frontend coverage
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: ./frontend-coverage
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Test Summary
|
||||||
|
run: |
|
||||||
|
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✅ Backend tests completed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✅ Frontend tests completed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Coverage reports uploaded as artifacts." >> $GITHUB_STEP_SUMMARY
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
This document describes how to run and write tests for the NAS Dashboard project.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The project has comprehensive test coverage for both backend (Python/FastAPI) and frontend (Svelte 5):
|
||||||
|
|
||||||
|
- **Backend**: pytest with unit and integration tests
|
||||||
|
- **Frontend**: Vitest with unit and component tests
|
||||||
|
- **CI**: Automated testing via Gitea Actions
|
||||||
|
|
||||||
|
## Backend Testing
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard/backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
pytest --cov=. --cov-report=term --cov-report=html
|
||||||
|
|
||||||
|
# Run only unit tests
|
||||||
|
pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# Run only integration tests
|
||||||
|
pytest tests/integration/ -v
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/unit/test_auth.py -v
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
pytest tests/unit/test_auth.py::TestPasswordHashing::test_hash_and_verify_password -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard/backend/tests/
|
||||||
|
├── conftest.py # Shared fixtures
|
||||||
|
├── unit/
|
||||||
|
│ ├── test_auth.py # JWT, TOTP, password hashing
|
||||||
|
│ ├── test_rbac.py # Role-based access control
|
||||||
|
│ └── test_config.py # IP parsing, env validation
|
||||||
|
└── integration/
|
||||||
|
├── test_auth_flow.py # Login, refresh, logout flows
|
||||||
|
├── test_docker.py # Container operations
|
||||||
|
└── test_files.py # File operations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Fixtures
|
||||||
|
|
||||||
|
- `mock_config`: Mocked configuration with test values
|
||||||
|
- `temp_auth_file`: Temporary auth.json for testing
|
||||||
|
- `temp_rbac_file`: Temporary rbac.json for testing
|
||||||
|
- `valid_access_token`: Valid JWT access token
|
||||||
|
- `admin_headers`: Headers with admin authentication
|
||||||
|
- `mock_docker_client`: Mocked Docker SDK client
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
Example unit test:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_hash_and_verify_password(self):
|
||||||
|
"""Test that password hashing and verification works."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hashed != password
|
||||||
|
assert verify_password(password, hashed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Example integration test:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test successful login without 2FA."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["user"] == "testadmin"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Testing
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd dashboard/frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run in watch mode
|
||||||
|
npm test -- --watch
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
npm test -- tests/unit/api.test.js
|
||||||
|
|
||||||
|
# Run with UI
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dashboard/frontend/tests/
|
||||||
|
├── setup.js # Global test setup
|
||||||
|
├── unit/
|
||||||
|
│ └── api.test.js # API client tests
|
||||||
|
├── components/
|
||||||
|
│ └── Login.test.js # Login component tests
|
||||||
|
└── integration/
|
||||||
|
└── (future tests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocked APIs
|
||||||
|
|
||||||
|
The test setup (`tests/setup.js`) provides mocks for:
|
||||||
|
|
||||||
|
- `fetch` - HTTP requests
|
||||||
|
- `WebSocket` - WebSocket connections
|
||||||
|
- `SpeechRecognition` / `speechSynthesis` - Web Speech API
|
||||||
|
- `navigator.credentials` - WebAuthn
|
||||||
|
- `localStorage` - Local storage
|
||||||
|
- `ResizeObserver` - Resize observer
|
||||||
|
- `MutationObserver` - Mutation observer
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
Example unit test:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should set and get token', () => {
|
||||||
|
setToken('test-token-123');
|
||||||
|
expect(getToken()).toBe('test-token-123');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Example component test:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it('should render login form', () => {
|
||||||
|
render(Login);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/username/i)).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText(/password/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
- **Backend**: 80%+ overall, 95%+ for critical paths (auth, RBAC)
|
||||||
|
- **Frontend**: 70%+ overall, 80%+ for critical components
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
Tests run automatically on:
|
||||||
|
- Push to `main` branch
|
||||||
|
- Pull requests to `main`
|
||||||
|
|
||||||
|
The CI workflow (`.gitea/workflows/test.yml`) runs:
|
||||||
|
1. Backend unit tests
|
||||||
|
2. Backend integration tests
|
||||||
|
3. Frontend tests with coverage
|
||||||
|
4. Uploads coverage reports as artifacts
|
||||||
|
|
||||||
|
### Viewing CI Results
|
||||||
|
|
||||||
|
1. Go to the Gitea repository
|
||||||
|
2. Click on "Actions" tab
|
||||||
|
3. Select the workflow run
|
||||||
|
4. Download coverage artifacts
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Backend Tests
|
||||||
|
|
||||||
|
**Issue**: `ModuleNotFoundError: No module named 'fastapi'`
|
||||||
|
**Solution**: Install dependencies: `pip install -r requirements.txt -r requirements-dev.txt`
|
||||||
|
|
||||||
|
**Issue**: `RuntimeError: SECRET_KEY must be set`
|
||||||
|
**Solution**: Tests use mocked config, ensure `conftest.py` is loaded
|
||||||
|
|
||||||
|
**Issue**: Permission denied on temp files
|
||||||
|
**Solution**: Check that `/tmp` is writable
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
**Issue**: `Cannot find module '@testing-library/svelte'`
|
||||||
|
**Solution**: Install dependencies: `npm install`
|
||||||
|
|
||||||
|
**Issue**: `ReferenceError: WebSocket is not defined`
|
||||||
|
**Solution**: Ensure `tests/setup.js` is loaded (check `vitest.config.js`)
|
||||||
|
|
||||||
|
**Issue**: Component tests fail with Svelte 5 runes
|
||||||
|
**Solution**: Use `@testing-library/svelte` v5.2.3+ which supports Svelte 5
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Use fixtures**: Leverage shared fixtures from `conftest.py`
|
||||||
|
2. **Mock external services**: Always mock Docker, SSH, HTTP clients
|
||||||
|
3. **Test edge cases**: Invalid inputs, expired tokens, permission errors
|
||||||
|
4. **Atomic tests**: Each test should be independent
|
||||||
|
5. **Clear test names**: Use descriptive names that explain what's being tested
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **Test behavior, not implementation**: Focus on user interactions
|
||||||
|
2. **Mock API calls**: Use `vi.fn()` to mock fetch responses
|
||||||
|
3. **Test accessibility**: Use `getByRole`, `getByLabelText` from testing-library
|
||||||
|
4. **Avoid testing internal state**: Test what the user sees
|
||||||
|
5. **Keep tests simple**: One assertion per test when possible
|
||||||
|
|
||||||
|
## Debugging Tests
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with verbose output
|
||||||
|
pytest -vv
|
||||||
|
|
||||||
|
# Run with print statements visible
|
||||||
|
pytest -s
|
||||||
|
|
||||||
|
# Run with debugger on failure
|
||||||
|
pytest --pdb
|
||||||
|
|
||||||
|
# Run specific test with full output
|
||||||
|
pytest tests/unit/test_auth.py::TestPasswordHashing -vv -s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run with verbose output
|
||||||
|
npm test -- --reporter=verbose
|
||||||
|
|
||||||
|
# Run in UI mode for debugging
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Run single test file in watch mode
|
||||||
|
npm test -- tests/unit/api.test.js --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Create test file in appropriate directory (`tests/unit/` or `tests/integration/`)
|
||||||
|
2. Import necessary fixtures from `conftest.py`
|
||||||
|
3. Write test classes and methods
|
||||||
|
4. Run tests to verify
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. Create test file in appropriate directory (`tests/unit/`, `tests/components/`, or `tests/integration/`)
|
||||||
|
2. Import necessary mocks and utilities
|
||||||
|
3. Write test cases using `describe` and `it`
|
||||||
|
4. Run tests to verify
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [pytest documentation](https://docs.pytest.org/)
|
||||||
|
- [Vitest documentation](https://vitest.dev/)
|
||||||
|
- [Testing Library documentation](https://testing-library.com/)
|
||||||
|
- [FastAPI testing guide](https://fastapi.tiangolo.com/tutorial/testing/)
|
||||||
|
- [Svelte testing guide](https://svelte.dev/docs/testing)
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# Testing Infrastructure Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Add comprehensive unit and integration testing for the NAS Dashboard backend (Python/FastAPI) and frontend (Svelte 5). Currently, the codebase has zero test coverage and relies only on smoke tests and manual verification.
|
||||||
|
|
||||||
|
## Backend Testing Strategy
|
||||||
|
|
||||||
|
### 1. Testing Framework Setup
|
||||||
|
- **Framework**: pytest 8.x with pytest-asyncio for async tests
|
||||||
|
- **Coverage**: pytest-cov for coverage reporting
|
||||||
|
- **Mocking**: pytest-mock + unittest.mock
|
||||||
|
- **HTTP Testing**: httpx for TestClient (FastAPI built-in)
|
||||||
|
- **Fixtures**: Centralized fixtures for common test data
|
||||||
|
|
||||||
|
### 2. Test Structure
|
||||||
|
```
|
||||||
|
dashboard/backend/
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── conftest.py # Shared fixtures
|
||||||
|
│ ├── unit/
|
||||||
|
│ │ ├── test_auth.py # JWT, TOTP, password hashing
|
||||||
|
│ │ ├── test_rbac.py # Permission checks, role resolution
|
||||||
|
│ │ ├── test_config.py # IP parsing, env validation
|
||||||
|
│ │ └── test_utils.py # Path validation, encryption
|
||||||
|
│ └── integration/
|
||||||
|
│ ├── test_auth_flow.py # Login, refresh, logout flows
|
||||||
|
│ ├── test_docker.py # Container operations (mocked)
|
||||||
|
│ ├── test_files.py # File operations with permissions
|
||||||
|
│ ├── test_terminal.py # WebSocket terminal (mocked SSH)
|
||||||
|
│ ├── test_passkey.py # WebAuthn flows
|
||||||
|
│ └── test_security.py # Rate limiting, audit logging
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Key Test Areas
|
||||||
|
|
||||||
|
#### Unit Tests (High Priority)
|
||||||
|
1. **auth.py** (~300 lines to test)
|
||||||
|
- JWT token generation/validation
|
||||||
|
- Token expiry and refresh logic
|
||||||
|
- TOTP generation/verification
|
||||||
|
- Password hashing/verification
|
||||||
|
- Token version management
|
||||||
|
- Encryption/decryption (Fernet + legacy fallback)
|
||||||
|
|
||||||
|
2. **rbac.py** (~200 lines to test)
|
||||||
|
- Role resolution (admin/member/viewer)
|
||||||
|
- Page access checks
|
||||||
|
- Sidebar link filtering
|
||||||
|
- User override logic
|
||||||
|
- Atomic JSON persistence
|
||||||
|
|
||||||
|
3. **config.py** (~150 lines to test)
|
||||||
|
- IP range parsing (LAN, Tailscale, trusted proxies)
|
||||||
|
- Environment variable validation
|
||||||
|
- Default value handling
|
||||||
|
|
||||||
|
4. **Path validation utilities**
|
||||||
|
- Traversal protection
|
||||||
|
- Symlink resolution
|
||||||
|
- Blocked directory checks
|
||||||
|
|
||||||
|
#### Integration Tests (High Priority)
|
||||||
|
1. **Authentication Flow** (routers/auth.py - 316 lines)
|
||||||
|
- POST /api/auth/login → TOTP required → token issued
|
||||||
|
- POST /api/auth/refresh → new tokens
|
||||||
|
- POST /api/auth/logout → token revoked
|
||||||
|
- Proxy auth with Remote-User header
|
||||||
|
- Rate limiting enforcement
|
||||||
|
|
||||||
|
2. **Docker Operations** (routers/docker_router.py - 37 lines)
|
||||||
|
- GET /api/docker/containers → list with health status
|
||||||
|
- POST /api/docker/containers/{id}/start → admin only
|
||||||
|
- GET /api/docker/containers/{id}/logs → tail limit
|
||||||
|
|
||||||
|
3. **File Operations** (routers/files.py - 128 lines)
|
||||||
|
- GET /api/files/browse → directory listing
|
||||||
|
- POST /api/files/upload → 100MB limit, admin only
|
||||||
|
- DELETE /api/files/delete → permission checks
|
||||||
|
|
||||||
|
4. **Terminal WebSocket** (routers/terminal.py - 220 lines)
|
||||||
|
- WebSocket /ws/terminal?host=nas → auth validation
|
||||||
|
- Session reservation (max 5 global, 5 per user)
|
||||||
|
- SSH connection lifecycle (mocked)
|
||||||
|
- Keepalive ping/pong
|
||||||
|
|
||||||
|
5. **Passkey/WebAuthn** (routers/passkey.py - 188 lines)
|
||||||
|
- Registration challenge generation
|
||||||
|
- Credential verification
|
||||||
|
- Credential management
|
||||||
|
|
||||||
|
### 4. Mocking Strategy
|
||||||
|
|
||||||
|
**Docker SDK** (docker.DockerClient):
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_docker_client(mocker):
|
||||||
|
client = mocker.Mock()
|
||||||
|
client.containers.list.return_value = [
|
||||||
|
mocker.Mock(id="abc123", name="test-container", status="running")
|
||||||
|
]
|
||||||
|
return client
|
||||||
|
```
|
||||||
|
|
||||||
|
**asyncssh** (SSH connections):
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def mock_ssh_connection(mocker):
|
||||||
|
conn = mocker.AsyncMock()
|
||||||
|
conn.create_process.return_value = mocker.AsyncMock()
|
||||||
|
mocker.patch('asyncssh.connect', return_value=conn)
|
||||||
|
return conn
|
||||||
|
```
|
||||||
|
|
||||||
|
**httpx** (External API calls):
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_httpx_client(mocker):
|
||||||
|
client = mocker.AsyncMock()
|
||||||
|
mocker.patch('httpx.AsyncClient', return_value=client)
|
||||||
|
return client
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test Fixtures (conftest.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def test_app():
|
||||||
|
"""FastAPI test client with overridden dependencies"""
|
||||||
|
from main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_jwt_token():
|
||||||
|
"""Generate valid JWT for testing"""
|
||||||
|
return create_access_token({"sub": "testuser", "role": "admin"})
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_rbac_data():
|
||||||
|
"""Sample RBAC configuration"""
|
||||||
|
return {
|
||||||
|
"role_defaults": {"admin": {"pages": "*", "sidebar_links": "*"}},
|
||||||
|
"user_overrides": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_auth_file(tmp_path):
|
||||||
|
"""Temporary auth.json for testing"""
|
||||||
|
auth_file = tmp_path / "auth.json"
|
||||||
|
auth_file.write_text('{"totp_secrets": {}, "passkey_credentials": {}}')
|
||||||
|
return auth_file
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Coverage Goals
|
||||||
|
- **Target**: 80%+ overall coverage
|
||||||
|
- **Critical paths**: 95%+ (auth, RBAC, path validation)
|
||||||
|
- **Routers**: 70%+ (focus on happy path + error cases)
|
||||||
|
|
||||||
|
### 7. CI Integration
|
||||||
|
Add to `.gitea/workflows/test.yml`:
|
||||||
|
```yaml
|
||||||
|
name: Backend Tests
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: nas-runner
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Run pytest
|
||||||
|
run: |
|
||||||
|
cd dashboard/backend
|
||||||
|
pip install -r requirements.txt -r requirements-dev.txt
|
||||||
|
pytest --cov=. --cov-report=term --cov-report=html
|
||||||
|
- name: Upload coverage
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: dashboard/backend/htmlcov/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Testing Strategy
|
||||||
|
|
||||||
|
### 1. Testing Framework Setup
|
||||||
|
- **Framework**: Vitest 2.x (Vite-native, fast)
|
||||||
|
- **Component Testing**: @testing-library/svelte for Svelte 5
|
||||||
|
- **Mocking**: vi.mock() for modules, vi.fn() for functions
|
||||||
|
- **Coverage**: @vitest/coverage-v8
|
||||||
|
|
||||||
|
### 2. Test Structure
|
||||||
|
```
|
||||||
|
dashboard/frontend/
|
||||||
|
├── tests/
|
||||||
|
│ ├── setup.js # Global test setup
|
||||||
|
│ ├── unit/
|
||||||
|
│ │ ├── api.test.js # API client, token management
|
||||||
|
│ │ └── voice.test.js # VoiceSession class
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Sidebar.test.js # Navigation, drag-drop, RBAC
|
||||||
|
│ │ └── App.test.js # Routing, auth flow
|
||||||
|
│ └── integration/
|
||||||
|
│ ├── Login.test.js # Passkey + password flows
|
||||||
|
│ ├── Terminal.test.js # WebSocket, reconnection
|
||||||
|
│ ├── Docker.test.js # Container management
|
||||||
|
│ └── Files.test.js # File browser operations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Key Test Areas
|
||||||
|
|
||||||
|
#### Unit Tests
|
||||||
|
1. **api.js** (~200 lines)
|
||||||
|
- Token storage/retrieval
|
||||||
|
- Auto-refresh on 401
|
||||||
|
- Error handling
|
||||||
|
- Request methods (get, post, put, del)
|
||||||
|
|
||||||
|
2. **voice.js** (~150 lines)
|
||||||
|
- VoiceSession state management
|
||||||
|
- STT/TTS integration (mocked)
|
||||||
|
- Language switching
|
||||||
|
- Talk mode handling
|
||||||
|
|
||||||
|
#### Component Tests
|
||||||
|
1. **App.svelte** (~400 lines)
|
||||||
|
- Initial auth check
|
||||||
|
- Page routing via query params
|
||||||
|
- Theme toggle persistence
|
||||||
|
- User info loading
|
||||||
|
|
||||||
|
2. **Sidebar.svelte** (~800 lines)
|
||||||
|
- Category rendering
|
||||||
|
- Drag-drop reordering
|
||||||
|
- Role-based link filtering
|
||||||
|
- Collapse state persistence
|
||||||
|
|
||||||
|
3. **Login.svelte** (~230 lines)
|
||||||
|
- Passkey authentication flow
|
||||||
|
- Password + TOTP flow
|
||||||
|
- Error message display
|
||||||
|
- Form validation
|
||||||
|
|
||||||
|
4. **Terminal.svelte** (~652 lines)
|
||||||
|
- WebSocket connection lifecycle
|
||||||
|
- Tab management
|
||||||
|
- Reconnection with exponential backoff
|
||||||
|
- Auth failure recovery
|
||||||
|
- Image upload (drag-drop/paste)
|
||||||
|
|
||||||
|
5. **Docker.svelte** (~133 lines)
|
||||||
|
- Container list rendering
|
||||||
|
- Start/stop/restart actions
|
||||||
|
- Log modal display
|
||||||
|
|
||||||
|
6. **Files.svelte** (~151 lines)
|
||||||
|
- Directory navigation
|
||||||
|
- File upload
|
||||||
|
- Download/delete actions
|
||||||
|
|
||||||
|
### 4. Mocking Strategy
|
||||||
|
|
||||||
|
**Fetch API**:
|
||||||
|
```javascript
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login success', async () => {
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ access_token: 'test-token' })
|
||||||
|
});
|
||||||
|
// test logic
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**WebSocket**:
|
||||||
|
```javascript
|
||||||
|
class MockWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = WebSocket.CONNECTING;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.readyState = WebSocket.OPEN;
|
||||||
|
this.onopen?.();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
send(data) { /* mock */ }
|
||||||
|
close() { /* mock */ }
|
||||||
|
}
|
||||||
|
global.WebSocket = MockWebSocket;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Web Speech API**:
|
||||||
|
```javascript
|
||||||
|
global.SpeechRecognition = vi.fn(() => ({
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.speechSynthesis = {
|
||||||
|
speak: vi.fn(),
|
||||||
|
cancel: vi.fn()
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**xterm.js**:
|
||||||
|
```javascript
|
||||||
|
vi.mock('@xterm/xterm', () => ({
|
||||||
|
Terminal: vi.fn(() => ({
|
||||||
|
open: vi.fn(),
|
||||||
|
write: vi.fn(),
|
||||||
|
onData: vi.fn(),
|
||||||
|
dispose: vi.fn()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test Configuration (vitest.config.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html', 'lcov'],
|
||||||
|
exclude: ['node_modules/', 'tests/', 'dist/']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Coverage Goals
|
||||||
|
- **Target**: 70%+ overall coverage
|
||||||
|
- **Critical components**: 80%+ (Terminal, Login, App)
|
||||||
|
- **API client**: 90%+ (core infrastructure)
|
||||||
|
|
||||||
|
### 7. CI Integration
|
||||||
|
Add to `.gitea/workflows/test.yml`:
|
||||||
|
```yaml
|
||||||
|
frontend-tests:
|
||||||
|
runs-on: nas-runner
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
cd dashboard/frontend
|
||||||
|
npm ci
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
cd dashboard/frontend
|
||||||
|
npm run test -- --coverage
|
||||||
|
- name: Upload coverage
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: dashboard/frontend/coverage/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Backend Foundation (Priority: High)
|
||||||
|
1. Install pytest, pytest-asyncio, pytest-cov, pytest-mock
|
||||||
|
2. Create test directory structure
|
||||||
|
3. Write conftest.py with shared fixtures
|
||||||
|
4. Implement unit tests for auth.py (JWT, TOTP, encryption)
|
||||||
|
5. Implement unit tests for rbac.py (permissions, role resolution)
|
||||||
|
6. Implement unit tests for config.py (IP parsing)
|
||||||
|
|
||||||
|
**Estimated effort**: 4-6 hours
|
||||||
|
**Files created**: 6-8 test files, conftest.py, requirements-dev.txt
|
||||||
|
|
||||||
|
### Phase 2: Backend Integration Tests (Priority: High)
|
||||||
|
1. Mock Docker SDK for container tests
|
||||||
|
2. Mock asyncssh for terminal tests
|
||||||
|
3. Mock httpx for external API tests
|
||||||
|
4. Implement integration tests for auth flow (login, refresh, logout)
|
||||||
|
5. Implement integration tests for Docker operations
|
||||||
|
6. Implement integration tests for file operations
|
||||||
|
7. Implement integration tests for terminal WebSocket
|
||||||
|
|
||||||
|
**Estimated effort**: 6-8 hours
|
||||||
|
**Files created**: 7-10 integration test files
|
||||||
|
|
||||||
|
### Phase 3: Frontend Foundation (Priority: Medium)
|
||||||
|
1. Install vitest, @testing-library/svelte, @vitest/coverage-v8, jsdom
|
||||||
|
2. Create vitest.config.js
|
||||||
|
3. Create test directory structure and setup.js
|
||||||
|
4. Implement unit tests for api.js (token management, auto-refresh)
|
||||||
|
5. Implement unit tests for voice.js (VoiceSession)
|
||||||
|
|
||||||
|
**Estimated effort**: 3-4 hours
|
||||||
|
**Files created**: vitest.config.js, setup.js, 2-3 test files
|
||||||
|
|
||||||
|
### Phase 4: Frontend Component Tests (Priority: Medium)
|
||||||
|
1. Mock fetch, WebSocket, Web Speech API, xterm.js
|
||||||
|
2. Implement component tests for App.svelte (routing, auth)
|
||||||
|
3. Implement component tests for Login.svelte (passkey, password flows)
|
||||||
|
4. Implement component tests for Sidebar.svelte (drag-drop, RBAC)
|
||||||
|
5. Implement component tests for Terminal.svelte (WebSocket, reconnection)
|
||||||
|
6. Implement component tests for Docker.svelte (actions, logs)
|
||||||
|
7. Implement component tests for Files.svelte (browse, upload)
|
||||||
|
|
||||||
|
**Estimated effort**: 8-10 hours
|
||||||
|
**Files created**: 6-8 component test files
|
||||||
|
|
||||||
|
### Phase 5: CI Integration (Priority: Medium)
|
||||||
|
1. Create .gitea/workflows/test.yml
|
||||||
|
2. Configure pytest in CI with coverage reporting
|
||||||
|
3. Configure vitest in CI with coverage reporting
|
||||||
|
4. Add coverage badges to README (optional)
|
||||||
|
5. Configure coverage thresholds (fail if below target)
|
||||||
|
|
||||||
|
**Estimated effort**: 2-3 hours
|
||||||
|
**Files created**: 1 workflow file
|
||||||
|
|
||||||
|
### Phase 6: Documentation & Maintenance (Priority: Low)
|
||||||
|
1. Add TESTING.md with instructions for running tests
|
||||||
|
2. Document mocking patterns and fixtures
|
||||||
|
3. Add pre-commit hook for running tests (optional)
|
||||||
|
4. Set up coverage monitoring
|
||||||
|
|
||||||
|
**Estimated effort**: 1-2 hours
|
||||||
|
**Files created**: TESTING.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies to Add
|
||||||
|
|
||||||
|
### Backend (requirements-dev.txt)
|
||||||
|
```
|
||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
pytest-cov==6.0.0
|
||||||
|
pytest-mock==3.14.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (package.json devDependencies)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"@testing-library/svelte": "^5.2.3",
|
||||||
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
|
"jsdom": "^25.0.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. **Backend**: 80%+ test coverage with all critical paths tested
|
||||||
|
2. **Frontend**: 70%+ test coverage with key components tested
|
||||||
|
3. **CI**: Tests run automatically on push/PR with coverage reports
|
||||||
|
4. **Documentation**: Clear instructions for running and writing tests
|
||||||
|
5. **Maintainability**: Fixtures and mocks are reusable and well-documented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
**Risk**: Tests may be slow due to Docker/SSH mocking complexity
|
||||||
|
**Mitigation**: Use lightweight mocks, avoid actual Docker/SSH connections
|
||||||
|
|
||||||
|
**Risk**: Frontend tests may be brittle due to Svelte 5 runes
|
||||||
|
**Mitigation**: Use @testing-library/svelte best practices, test behavior not implementation
|
||||||
|
|
||||||
|
**Risk**: CI runner may lack resources for parallel test execution
|
||||||
|
**Mitigation**: Run tests sequentially if needed, optimize test fixtures
|
||||||
|
|
||||||
|
**Risk**: Existing code may need refactoring for testability
|
||||||
|
**Mitigation**: Minimal refactoring, focus on testing current behavior first
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should we add E2E tests with Playwright in addition to unit/integration tests?
|
||||||
|
- **Recommendation**: Start with unit/integration, add E2E later if needed
|
||||||
|
|
||||||
|
2. Should we enforce coverage thresholds in CI (fail build if below target)?
|
||||||
|
- **Recommendation**: Yes, but start with lenient thresholds (60%) and increase gradually
|
||||||
|
|
||||||
|
3. Should we test all 13 routers or focus on high-risk areas first?
|
||||||
|
- **Recommendation**: Focus on auth, docker, terminal, files first (80% of risk)
|
||||||
|
|
||||||
|
4. Should we mock the file system or use temporary directories?
|
||||||
|
- **Recommendation**: Use pytest's tmp_path fixture for real file operations
|
||||||
|
|
||||||
|
5. Should we test WebSocket reconnection logic in detail?
|
||||||
|
- **Recommendation**: Yes, it's complex and critical (Terminal.svelte has 652 lines)
|
||||||
+11
-4
@@ -16,6 +16,7 @@ RUN apt-get update \
|
|||||||
python3 \
|
python3 \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
rsync \
|
rsync \
|
||||||
|
tmux \
|
||||||
unzip \
|
unzip \
|
||||||
wget \
|
wget \
|
||||||
zip \
|
zip \
|
||||||
@@ -27,24 +28,30 @@ ARG YQ_VERSION=v4.44.6
|
|||||||
RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \
|
RUN wget -q -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" \
|
||||||
&& chmod +x /usr/local/bin/yq
|
&& chmod +x /usr/local/bin/yq
|
||||||
|
|
||||||
|
ARG TEA_VERSION=0.12.0
|
||||||
|
RUN wget -q -O /usr/local/bin/tea "https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
|
||||||
|
&& chmod +x /usr/local/bin/tea
|
||||||
|
|
||||||
RUN npm install -g @anthropic-ai/claude-code
|
RUN npm install -g @anthropic-ai/claude-code
|
||||||
|
|
||||||
RUN python3 - <<'PY'
|
RUN python3 - <<'PY'
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js")
|
path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js")
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise SystemExit(f"claude cli not found: {path}")
|
raise SystemExit(f"claude cli not found: {path}")
|
||||||
|
|
||||||
content = path.read_text()
|
content = path.read_text()
|
||||||
old = 'F8.get("https://api.anthropic.com/api/hello",{headers:K8($)}).catch((()=>!1))'
|
pattern = r'let A=U7\(\),q=new URL\(A\.TOKEN_URL\),K=\[`\$\{A\.BASE_API_URL\}/api/hello`,`\$\{q\.origin\}/v1/oauth/hello`\],Y=async\(_\)=>\{try\{let \$=await I8\.get\(_,\{headers:\{"User-Agent":ey\(\)\}\}\);if\(\$\.status!==200\)return\{success:!1,error:`Failed to connect to \$\{new URL\(_\)\.hostname\}: Status \$\{\$\.status\}`\};return\{success:!0\}\}catch\(\$\)\{'
|
||||||
if old not in content:
|
replacement = 'return'
|
||||||
|
patched, count = re.subn(pattern, replacement, content, count=1)
|
||||||
|
if count != 1:
|
||||||
raise SystemExit("preflight patch target not found in claude cli; refusing to build")
|
raise SystemExit("preflight patch target not found in claude cli; refusing to build")
|
||||||
|
|
||||||
patched = content.replace(old, "!0")
|
|
||||||
path.write_text(patched)
|
path.write_text(patched)
|
||||||
|
|
||||||
if old in path.read_text():
|
if re.search(pattern, path.read_text()):
|
||||||
raise SystemExit("preflight patch did not apply cleanly")
|
raise SystemExit("preflight patch did not apply cleanly")
|
||||||
|
|
||||||
print("Applied Claude preflight bypass patch")
|
print("Applied Claude preflight bypass patch")
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ rsync
|
|||||||
zip
|
zip
|
||||||
unzip
|
unzip
|
||||||
make
|
make
|
||||||
|
tmux
|
||||||
|
tea
|
||||||
claude
|
claude
|
||||||
|
|||||||
+47
-21
@@ -6,7 +6,7 @@ import tempfile
|
|||||||
import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import pyotp
|
import pyotp
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Response, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
import config
|
import config
|
||||||
|
|
||||||
@@ -14,6 +14,12 @@ import config
|
|||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
|
||||||
|
|
||||||
|
ACCESS_COOKIE_NAME = "nas_access_token"
|
||||||
|
REFRESH_COOKIE_NAME = "nas_refresh_token"
|
||||||
|
COOKIE_SECURE = True
|
||||||
|
COOKIE_SAMESITE = "lax"
|
||||||
|
COOKIE_PATH = "/"
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
@@ -26,18 +32,49 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
|||||||
to_encode.update({"exp": expire, "type": "access"})
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: dict):
|
def create_refresh_token(data: dict):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|
||||||
|
def _cookie_max_age(minutes: int) -> int:
|
||||||
|
return max(60, int(minutes * 60))
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
key=ACCESS_COOKIE_NAME,
|
||||||
|
value=access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=COOKIE_SECURE,
|
||||||
|
samesite=COOKIE_SAMESITE,
|
||||||
|
path=COOKIE_PATH,
|
||||||
|
max_age=_cookie_max_age(config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key=REFRESH_COOKIE_NAME,
|
||||||
|
value=refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=COOKIE_SECURE,
|
||||||
|
samesite=COOKIE_SAMESITE,
|
||||||
|
path=COOKIE_PATH,
|
||||||
|
max_age=_cookie_max_age(config.REFRESH_TOKEN_EXPIRE_MINUTES),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth_cookies(response: Response):
|
||||||
|
response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
|
||||||
|
response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH)
|
||||||
|
|
||||||
|
def user_from_access_token(token: str, include_auth_header: bool = True):
|
||||||
from rbac import User, get_pages, get_sidebar_links
|
from rbac import User, get_pages, get_sidebar_links
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
@@ -49,29 +86,18 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
|
||||||
pages = get_pages(username, role)
|
pages = get_pages(username, role)
|
||||||
sidebar_links = get_sidebar_links(username, role)
|
sidebar_links = get_sidebar_links(username, role)
|
||||||
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
|
return user_from_access_token(token)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_ws(token: str):
|
async def get_current_user_ws(token: str):
|
||||||
from rbac import User, get_pages, get_sidebar_links
|
return user_from_access_token(token, include_auth_header=False)
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
|
||||||
if payload.get("type") != "access":
|
|
||||||
raise credentials_exception
|
|
||||||
username: str = payload.get("sub")
|
|
||||||
role: str = payload.get("role", "admin")
|
|
||||||
if username is None:
|
|
||||||
raise credentials_exception
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
pages = get_pages(username, role)
|
|
||||||
sidebar_links = get_sidebar_links(username, role)
|
|
||||||
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
|
|
||||||
|
|
||||||
# TOTP Persistence
|
# TOTP Persistence
|
||||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|||||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||||
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
||||||
|
|
||||||
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com")
|
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
|
||||||
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
|
||||||
import auth as auth_module
|
import auth as auth_module
|
||||||
import config
|
import config
|
||||||
from rbac import require_page, require_write
|
from rbac import require_page
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
|
|||||||
|
|
||||||
# Protected endpoints with page-level RBAC
|
# Protected endpoints with page-level RBAC
|
||||||
app.include_router(docker_router.router, prefix="/api/docker",
|
app.include_router(docker_router.router, prefix="/api/docker",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
|
dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
|
||||||
app.include_router(gitea.router, prefix="/api/gitea",
|
app.include_router(gitea.router, prefix="/api/gitea",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
||||||
app.include_router(files.router, prefix="/api/files",
|
app.include_router(files.router, prefix="/api/files",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
|
dependencies=[Depends(_inject_user), Depends(require_page("files"))])
|
||||||
app.include_router(system.router, prefix="/api/system",
|
app.include_router(system.router, prefix="/api/system",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||||
app.include_router(litellm.router, prefix="/api/litellm",
|
app.include_router(litellm.router, prefix="/api/litellm",
|
||||||
@@ -178,7 +178,7 @@ app.include_router(info_engine.router, prefix="/api/info-engine",
|
|||||||
app.include_router(security.router, prefix="/api/security",
|
app.include_router(security.router, prefix="/api/security",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
||||||
|
|
||||||
# WebSocket endpoint (auth handled inside handler via Query param)
|
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
|
||||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pytest==8.3.4
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
pytest-cov==6.0.0
|
||||||
|
pytest-mock==3.14.0
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
@@ -15,11 +16,13 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
totp_code: str = ""
|
totp_code: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
@@ -28,8 +31,46 @@ class Token(BaseModel):
|
|||||||
has_2fa: bool
|
has_2fa: bool
|
||||||
role: str = "admin"
|
role: str = "admin"
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_auth_response_tokens(response: Response, access_token: str, refresh_token: str):
|
||||||
|
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
|
||||||
|
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
|
||||||
|
if cookie_token:
|
||||||
|
return cookie_token
|
||||||
|
if req and req.refresh_token:
|
||||||
|
return req.refresh_token
|
||||||
|
raise HTTPException(status_code=401, detail="Missing refresh token")
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_tokens_from_value(refresh_token_value: str):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(refresh_token_value, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||||
|
username = payload.get("sub")
|
||||||
|
role = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user")
|
||||||
|
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||||
|
raise HTTPException(status_code=401, detail="Token revoked")
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
|
|
||||||
|
auth.increment_token_version()
|
||||||
|
access_token = auth.create_access_token(
|
||||||
|
data={"sub": username, "role": role},
|
||||||
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
|
)
|
||||||
|
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
||||||
|
return access_token, refresh_token
|
||||||
|
|
||||||
|
|
||||||
async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
||||||
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
|
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
|
||||||
@@ -52,9 +93,10 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to send Telegram alert: %s", e)
|
logger.warning("Failed to send Telegram alert: %s", e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def login(creds: LoginRequest, request: Request):
|
async def login(creds: LoginRequest, request: Request, response: Response):
|
||||||
client_ip = request.client.host
|
client_ip = request.client.host
|
||||||
# Verify username/password
|
# Verify username/password
|
||||||
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
|
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
|
||||||
@@ -89,6 +131,7 @@ async def login(creds: LoginRequest, request: Request):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
||||||
|
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
@@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request):
|
|||||||
"role": "admin",
|
"role": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/proxy")
|
@router.get("/proxy")
|
||||||
async def proxy_auth(request: Request):
|
async def proxy_auth(request: Request, response: Response):
|
||||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||||
from rbac import resolve_role
|
from rbac import resolve_role
|
||||||
@@ -125,6 +169,7 @@ async def proxy_auth(request: Request):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
||||||
|
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
@@ -134,8 +179,9 @@ async def proxy_auth(request: Request):
|
|||||||
"role": role,
|
"role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def read_users_me(current_user = Depends(auth.get_current_user)):
|
async def read_users_me(current_user=Depends(auth.get_current_user)):
|
||||||
totp_secret = auth.load_totp_secret()
|
totp_secret = auth.load_totp_secret()
|
||||||
return {
|
return {
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
@@ -145,36 +191,31 @@ async def read_users_me(current_user = Depends(auth.get_current_user)):
|
|||||||
"has_2fa": bool(totp_secret),
|
"has_2fa": bool(totp_secret),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def refresh_token(req: RefreshRequest, request: Request):
|
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
|
||||||
try:
|
refresh_token_value = _extract_refresh_token(request, req)
|
||||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
||||||
if payload.get("type") != "refresh":
|
_set_auth_response_tokens(response, access_token, refresh_token)
|
||||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
|
||||||
username = payload.get("sub")
|
|
||||||
role = payload.get("role", "admin")
|
|
||||||
if username is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid user")
|
|
||||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
|
||||||
raise HTTPException(status_code=401, detail="Token revoked")
|
|
||||||
except jwt.PyJWTError:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
||||||
auth.increment_token_version()
|
|
||||||
access_token = auth.create_access_token(
|
|
||||||
data={"sub": username, "role": role},
|
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
|
||||||
)
|
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
|
||||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
return {"access_token": access_token, "refresh_token": refresh_token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
auth.increment_token_version()
|
||||||
|
auth.clear_auth_cookies(response)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)):
|
async def change_password(req: ChangePasswordRequest, request: Request, current_user=Depends(auth.get_current_user)):
|
||||||
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
||||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
if len(req.new_password) < 8:
|
if len(req.new_password) < 8:
|
||||||
@@ -182,16 +223,18 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
|
|||||||
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
auth.save_password_hash(auth.get_password_hash(req.new_password))
|
||||||
return {"message": "Password changed successfully"}
|
return {"message": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
# RBAC admin endpoints
|
# RBAC admin endpoints
|
||||||
@router.get("/rbac/config")
|
@router.get("/rbac/config")
|
||||||
async def rbac_config(current_user = Depends(auth.get_current_user)):
|
async def rbac_config(current_user=Depends(auth.get_current_user)):
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import load_rbac
|
from rbac import load_rbac
|
||||||
return load_rbac()
|
return load_rbac()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rbac/overrides/{username}")
|
@router.put("/rbac/overrides/{username}")
|
||||||
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
|
async def rbac_set_override(username: str, request: Request, current_user=Depends(auth.get_current_user)):
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
@@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
|
|||||||
update_rbac(mutator)
|
update_rbac(mutator)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preferences")
|
@router.get("/preferences")
|
||||||
async def get_preferences(current_user = Depends(auth.get_current_user)):
|
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||||
from rbac import get_sidebar_order
|
from rbac import get_sidebar_order
|
||||||
order = get_sidebar_order(current_user.username)
|
order = get_sidebar_order(current_user.username)
|
||||||
return {"sidebar_order": order}
|
return {"sidebar_order": order}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/preferences")
|
@router.put("/preferences")
|
||||||
async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)):
|
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
||||||
import traceback
|
import traceback
|
||||||
from rbac import save_sidebar_order
|
from rbac import save_sidebar_order
|
||||||
try:
|
try:
|
||||||
@@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/rbac/overrides/{username}")
|
@router.delete("/rbac/overrides/{username}")
|
||||||
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
|
async def rbac_delete_override(username: str, current_user=Depends(auth.get_current_user)):
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
@@ -244,8 +290,9 @@ async def rbac_delete_override(username: str, current_user = Depends(auth.get_cu
|
|||||||
update_rbac(mutator)
|
update_rbac(mutator)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/rbac/roles/{role}")
|
@router.put("/rbac/roles/{role}")
|
||||||
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
|
async def rbac_update_role(role: str, request: Request, current_user=Depends(auth.get_current_user)):
|
||||||
if current_user.role != "admin":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import docker
|
import docker
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
|
from rbac import require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||||
@@ -21,7 +22,7 @@ def list_containers():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/containers/{container_id}/{action}")
|
@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())])
|
||||||
def container_action(container_id: str, action: str):
|
def container_action(container_id: str, action: str):
|
||||||
if action not in ("start", "stop", "restart"):
|
if action not in ("start", "stop", "restart"):
|
||||||
return {"error": "invalid action"}
|
return {"error": "invalid action"}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from config import VOLUME_ROOT
|
from config import VOLUME_ROOT
|
||||||
|
from rbac import require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
BASE = Path(VOLUME_ROOT)
|
BASE = Path(VOLUME_ROOT)
|
||||||
@@ -79,7 +80,7 @@ def download(path: str):
|
|||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload", dependencies=[Depends(require_admin())])
|
||||||
async def upload(path: str, file: UploadFile = File(...)):
|
async def upload(path: str, file: UploadFile = File(...)):
|
||||||
try:
|
try:
|
||||||
safe_name = _sanitize_filename(file.filename)
|
safe_name = _sanitize_filename(file.filename)
|
||||||
@@ -99,7 +100,7 @@ async def upload(path: str, file: UploadFile = File(...)):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/delete")
|
@router.delete("/delete", dependencies=[Depends(require_admin())])
|
||||||
def delete(path: str, recursive: bool = False):
|
def delete(path: str, recursive: bool = False):
|
||||||
try:
|
try:
|
||||||
target = _safe_path(path)
|
target = _safe_path(path)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
|
|||||||
|
|
||||||
@router.post("/passkey/login/verify")
|
@router.post("/passkey/login/verify")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def passkey_login_verify(request: Request):
|
async def passkey_login_verify(request: Request, response: Response):
|
||||||
"""Verify passkey authentication and issue tokens."""
|
"""Verify passkey authentication and issue tokens."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
||||||
@@ -146,6 +146,7 @@ async def passkey_login_verify(request: Request):
|
|||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
||||||
|
auth.set_auth_cookies(response, access_token, refresh_token)
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import asyncio
|
|||||||
import struct
|
import struct
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
|
import jwt
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fastapi import WebSocket, Query
|
from fastapi import WebSocket, Query
|
||||||
import asyncssh
|
import asyncssh
|
||||||
import config
|
import config
|
||||||
from auth import get_current_user_ws
|
import auth
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -90,18 +91,29 @@ async def _release_session(session_id: int | None):
|
|||||||
_active_sessions.pop(session_id, None)
|
_active_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)):
|
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
||||||
if host not in HOSTS:
|
if host not in HOSTS:
|
||||||
await websocket.close(code=1008, reason="Unknown host")
|
await websocket.close(code=1008, reason="Unknown host")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not token:
|
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME)
|
||||||
|
if not access_token:
|
||||||
|
logger.warning("WebSocket auth failed for %s: no access token cookie", host)
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await get_current_user_ws(token)
|
user = await auth.get_current_user_ws(access_token)
|
||||||
except Exception:
|
except jwt.ExpiredSignatureError as e:
|
||||||
|
logger.warning("WebSocket auth failed for %s: token expired - %s", host, e)
|
||||||
|
await websocket.close(code=1008, reason="Token expired")
|
||||||
|
return
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.warning("WebSocket auth failed for %s: invalid token - %s", host, e)
|
||||||
|
await websocket.close(code=1008, reason="Invalid token")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("WebSocket auth failed for %s: unexpected error - %s", host, e)
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -121,16 +133,27 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
session_id = session_or_code
|
session_id = session_or_code
|
||||||
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
logger.info("WebSocket accepted for %s (user: %s)", host, user.username)
|
||||||
|
|
||||||
h = HOSTS[host]
|
h = HOSTS[host]
|
||||||
try:
|
try:
|
||||||
conn = await asyncssh.connect(
|
conn = await asyncio.wait_for(
|
||||||
h["host"], username=h["user"],
|
asyncssh.connect(
|
||||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
h["host"], username=h["user"],
|
||||||
keepalive_interval=30,
|
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||||
keepalive_count_max=3,
|
keepalive_interval=15,
|
||||||
|
keepalive_count_max=8,
|
||||||
|
),
|
||||||
|
timeout=10.0
|
||||||
)
|
)
|
||||||
except Exception:
|
logger.info("SSH connection established to %s", host)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("SSH connection to %s timed out after 10s", host)
|
||||||
|
await _release_session(session_id)
|
||||||
|
await websocket.close(code=1011, reason="SSH connection timeout")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("SSH connection to %s failed: %s", host, e)
|
||||||
await _release_session(session_id)
|
await _release_session(session_id)
|
||||||
await websocket.close(code=1011, reason="SSH connection failed")
|
await websocket.close(code=1011, reason="SSH connection failed")
|
||||||
return
|
return
|
||||||
@@ -148,8 +171,12 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
await websocket.send_bytes(data)
|
await websocket.send_bytes(data)
|
||||||
except (asyncssh.Error, asyncio.CancelledError):
|
except asyncssh.Error as e:
|
||||||
|
logger.warning("SSH read error for %s: %s", host, e)
|
||||||
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unexpected error reading SSH for %s: %s", host, e)
|
||||||
|
|
||||||
read_task = asyncio.create_task(read_ssh())
|
read_task = asyncio.create_task(read_ssh())
|
||||||
|
|
||||||
@@ -163,16 +190,31 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
|
|||||||
rows = struct.unpack("!H", data[3:5])[0]
|
rows = struct.unpack("!H", data[3:5])[0]
|
||||||
process.channel.change_terminal_size(cols, rows)
|
process.channel.change_terminal_size(cols, rows)
|
||||||
else:
|
else:
|
||||||
process.stdin.write(data)
|
try:
|
||||||
|
process.stdin.write(data)
|
||||||
|
except (BrokenPipeError, OSError) as e:
|
||||||
|
logger.warning("Failed to write to stdin for %s: %s", host, e)
|
||||||
|
break
|
||||||
elif "text" in msg and msg["text"]:
|
elif "text" in msg and msg["text"]:
|
||||||
if msg["text"] == "__ping__":
|
if msg["text"] == "__ping__":
|
||||||
|
logger.debug("Received ping from %s, sending pong", host)
|
||||||
|
await websocket.send_text("__pong__")
|
||||||
continue
|
continue
|
||||||
process.stdin.write(msg["text"].encode())
|
try:
|
||||||
except Exception:
|
process.stdin.write(msg["text"].encode())
|
||||||
pass
|
except (BrokenPipeError, OSError) as e:
|
||||||
|
logger.warning("Failed to write to stdin for %s: %s", host, e)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("WebSocket receive loop ended for %s: %s", host, e)
|
||||||
finally:
|
finally:
|
||||||
read_task.cancel()
|
read_task.cancel()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(read_task, timeout=2.0)
|
||||||
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||||
|
pass
|
||||||
process.close()
|
process.close()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
await conn.wait_closed()
|
||||||
await _release_session(session_id)
|
await _release_session(session_id)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Backend tests
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Shared pytest fixtures for backend tests.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from datetime import timedelta
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from unittest.mock import Mock, AsyncMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_volume_root(tmp_path):
|
||||||
|
"""Create temporary volume root for testing."""
|
||||||
|
volume_root = tmp_path / "volume1"
|
||||||
|
volume_root.mkdir()
|
||||||
|
(volume_root / "docker" / "nas-dashboard").mkdir(parents=True)
|
||||||
|
return str(volume_root)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config(temp_volume_root, monkeypatch):
|
||||||
|
"""Mock config module with test values."""
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "test-secret-key-at-least-32-chars-long")
|
||||||
|
monkeypatch.setenv("ADMIN_USER", "testadmin")
|
||||||
|
monkeypatch.setenv("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef")
|
||||||
|
monkeypatch.setenv("VOLUME_ROOT", temp_volume_root)
|
||||||
|
monkeypatch.setenv("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||||
|
monkeypatch.setenv("GITEA_URL", "http://mock-gitea:3000")
|
||||||
|
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
||||||
|
|
||||||
|
# Reload config module to pick up new env vars
|
||||||
|
import config
|
||||||
|
import importlib
|
||||||
|
importlib.reload(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_auth_file(temp_volume_root):
|
||||||
|
"""Create temporary auth.json file."""
|
||||||
|
auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json")
|
||||||
|
data = {
|
||||||
|
"totp_secret": "",
|
||||||
|
"passkey_credentials": [],
|
||||||
|
"token_version": 0,
|
||||||
|
"last_totp_ts": 0
|
||||||
|
}
|
||||||
|
with open(auth_file, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
return auth_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_rbac_file(temp_volume_root):
|
||||||
|
"""Create temporary rbac.json file."""
|
||||||
|
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||||
|
data = {
|
||||||
|
"role_defaults": {
|
||||||
|
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||||
|
"member": {"pages": ["dashboard", "docker"], "sidebar_links": "*"},
|
||||||
|
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}
|
||||||
|
},
|
||||||
|
"user_overrides": {}
|
||||||
|
}
|
||||||
|
with open(rbac_file, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
return rbac_file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_access_token(mock_config):
|
||||||
|
"""Generate a valid access token for testing."""
|
||||||
|
from auth import create_access_token
|
||||||
|
return create_access_token(
|
||||||
|
data={"sub": "testuser", "role": "admin"},
|
||||||
|
expires_delta=timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def valid_refresh_token(mock_config, temp_auth_file):
|
||||||
|
"""Generate a valid refresh token for testing."""
|
||||||
|
from auth import create_refresh_token
|
||||||
|
return create_refresh_token(data={"sub": "testuser", "role": "admin"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def expired_access_token(mock_config):
|
||||||
|
"""Generate an expired access token for testing."""
|
||||||
|
from auth import create_access_token
|
||||||
|
return create_access_token(
|
||||||
|
data={"sub": "testuser", "role": "admin"},
|
||||||
|
expires_delta=timedelta(seconds=-1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_docker_client():
|
||||||
|
"""Mock Docker client for testing."""
|
||||||
|
client = Mock()
|
||||||
|
|
||||||
|
# Mock container
|
||||||
|
container = Mock()
|
||||||
|
container.id = "abc123"
|
||||||
|
container.name = "test-container"
|
||||||
|
container.status = "running"
|
||||||
|
container.attrs = {
|
||||||
|
"State": {"Health": {"Status": "healthy"}},
|
||||||
|
"Config": {"Image": "test-image:latest"}
|
||||||
|
}
|
||||||
|
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||||
|
|
||||||
|
client.containers.list.return_value = [container]
|
||||||
|
client.containers.get.return_value = container
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_ssh_connection():
|
||||||
|
"""Mock asyncssh connection for testing."""
|
||||||
|
conn = AsyncMock()
|
||||||
|
process = AsyncMock()
|
||||||
|
process.stdout = AsyncMock()
|
||||||
|
process.stdin = AsyncMock()
|
||||||
|
process.stderr = AsyncMock()
|
||||||
|
conn.create_process.return_value = process
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_httpx_client():
|
||||||
|
"""Mock httpx AsyncClient for testing."""
|
||||||
|
client = AsyncMock()
|
||||||
|
response = AsyncMock()
|
||||||
|
response.status_code = 200
|
||||||
|
response.json.return_value = {"status": "ok"}
|
||||||
|
client.get.return_value = response
|
||||||
|
client.post.return_value = response
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||||
|
"""Create FastAPI test client with mocked dependencies."""
|
||||||
|
# Patch Docker client before importing main
|
||||||
|
with patch("docker.DockerClient", return_value=mock_docker_client):
|
||||||
|
from main import app
|
||||||
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_headers(valid_access_token):
|
||||||
|
"""Headers with admin access token."""
|
||||||
|
return {"Authorization": f"Bearer {valid_access_token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_rbac_data():
|
||||||
|
"""Sample RBAC configuration for testing."""
|
||||||
|
return {
|
||||||
|
"role_defaults": {
|
||||||
|
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||||
|
"member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"},
|
||||||
|
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}
|
||||||
|
},
|
||||||
|
"user_overrides": {
|
||||||
|
"customuser": {
|
||||||
|
"pages": ["dashboard", "docker"],
|
||||||
|
"sidebar_links": ["dashboard", "docker", "files"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_totp_secret():
|
||||||
|
"""Sample TOTP secret for testing."""
|
||||||
|
return "JBSWY3DPEHPK3PXP" # Base32 encoded secret
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request():
|
||||||
|
"""Mock FastAPI Request object."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "192.168.31.100"
|
||||||
|
request.headers = {}
|
||||||
|
return request
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Integration tests
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for authentication flow.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pyotp
|
||||||
|
from fastapi import status
|
||||||
|
from auth import get_password_hash, save_totp_secret, save_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginFlow:
|
||||||
|
"""Test login endpoint."""
|
||||||
|
|
||||||
|
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test successful login without 2FA."""
|
||||||
|
# Set up password
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["user"] == "testadmin"
|
||||||
|
assert data["role"] == "admin"
|
||||||
|
assert data["has_2fa"] is False
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test successful login with 2FA."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
|
totp = pyotp.TOTP(sample_totp_secret)
|
||||||
|
valid_code = totp.now()
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["user"] == "testadmin"
|
||||||
|
assert data["has_2fa"] is True
|
||||||
|
|
||||||
|
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test login with wrong password."""
|
||||||
|
password = "correct_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid credentials" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test login with wrong username."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test login with 2FA enabled but no code provided."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "2FA code required" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test login with invalid 2FA code."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test that login sets auth cookies."""
|
||||||
|
password = "test_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check that cookies are set
|
||||||
|
assert "nas_access_token" in response.cookies
|
||||||
|
assert "nas_refresh_token" in response.cookies
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshFlow:
|
||||||
|
"""Test token refresh endpoint."""
|
||||||
|
|
||||||
|
def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||||
|
"""Test refreshing with valid refresh token."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": valid_refresh_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["access_token"] != valid_refresh_token
|
||||||
|
|
||||||
|
def test_refresh_with_cookie(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||||
|
"""Test refreshing with refresh token in cookie."""
|
||||||
|
test_app.cookies.set("nas_refresh_token", valid_refresh_token)
|
||||||
|
|
||||||
|
response = test_app.post("/api/auth/refresh", json={})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
|
||||||
|
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test refreshing with invalid token."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": "invalid.token.here"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
||||||
|
"""Test refreshing with expired token."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": expired_access_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_refresh_missing_token(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test refresh without providing token."""
|
||||||
|
response = test_app.post("/api/auth/refresh", json={})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Missing refresh token" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_refresh_with_access_token_fails(self, test_app, mock_config, temp_auth_file, valid_access_token):
|
||||||
|
"""Test that access token cannot be used for refresh."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/refresh",
|
||||||
|
json={"refresh_token": valid_access_token}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "Invalid token type" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogoutFlow:
|
||||||
|
"""Test logout endpoint."""
|
||||||
|
|
||||||
|
def test_logout_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
|
"""Test successful logout."""
|
||||||
|
response = test_app.post("/api/auth/logout", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "Logged out successfully"
|
||||||
|
|
||||||
|
def test_logout_clears_cookies(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
|
"""Test that logout clears auth cookies."""
|
||||||
|
response = test_app.post("/api/auth/logout", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Cookies should be deleted (max-age=0 or expires in past)
|
||||||
|
# TestClient doesn't fully simulate cookie deletion, but we can check response
|
||||||
|
|
||||||
|
def test_logout_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test logout without authentication."""
|
||||||
|
response = test_app.post("/api/auth/logout")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserInfo:
|
||||||
|
"""Test user info endpoint."""
|
||||||
|
|
||||||
|
def test_get_user_info(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||||
|
"""Test getting current user info."""
|
||||||
|
response = test_app.get("/api/auth/me", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["username"] == "testuser"
|
||||||
|
assert data["role"] == "admin"
|
||||||
|
assert "pages" in data
|
||||||
|
assert "sidebar_links" in data
|
||||||
|
|
||||||
|
def test_get_user_info_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test getting user info without authentication."""
|
||||||
|
response = test_app.get("/api/auth/me")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestProxyAuth:
|
||||||
|
"""Test proxy authentication endpoint."""
|
||||||
|
|
||||||
|
def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
|
"""Test proxy auth from trusted proxy."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/auth/proxy",
|
||||||
|
headers={
|
||||||
|
"Remote-User": "proxyuser",
|
||||||
|
"Remote-Groups": "dashboard_admin"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: This test may need adjustment based on actual proxy auth implementation
|
||||||
|
# The test client's default host may not be in trusted proxies
|
||||||
|
assert response.status_code in [200, 403]
|
||||||
|
|
||||||
|
def test_proxy_auth_from_untrusted_source(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test proxy auth from untrusted source."""
|
||||||
|
# Mock request from untrusted IP
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/auth/proxy",
|
||||||
|
headers={
|
||||||
|
"Remote-User": "proxyuser",
|
||||||
|
"Remote-Groups": "dashboard_admin"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should reject untrusted proxy
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordChange:
|
||||||
|
"""Test password change endpoint."""
|
||||||
|
|
||||||
|
def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
|
"""Test successful password change."""
|
||||||
|
old_password = "old_password"
|
||||||
|
new_password = "new_password_123"
|
||||||
|
|
||||||
|
# Set initial password
|
||||||
|
hashed = get_password_hash(old_password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"old_password": old_password,
|
||||||
|
"new_password": new_password
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
|
"""Test password change with wrong old password."""
|
||||||
|
old_password = "old_password"
|
||||||
|
hashed = get_password_hash(old_password)
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
headers=admin_headers,
|
||||||
|
json={
|
||||||
|
"old_password": "wrong_password",
|
||||||
|
"new_password": "new_password_123"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||||
|
"""Test password change without authentication."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/auth/change-password",
|
||||||
|
json={
|
||||||
|
"old_password": "old",
|
||||||
|
"new_password": "new"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for Docker operations.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerContainerList:
|
||||||
|
"""Test listing Docker containers."""
|
||||||
|
|
||||||
|
def test_list_containers_success(self, test_app, admin_headers):
|
||||||
|
"""Test listing containers successfully."""
|
||||||
|
response = test_app.get("/api/docker/containers", headers=admin_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
if len(data) > 0:
|
||||||
|
container = data[0]
|
||||||
|
assert "id" in container
|
||||||
|
assert "name" in container
|
||||||
|
assert "status" in container
|
||||||
|
|
||||||
|
def test_list_containers_without_auth(self, test_app):
|
||||||
|
"""Test listing containers without authentication."""
|
||||||
|
response = test_app.get("/api/docker/containers")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerContainerActions:
|
||||||
|
"""Test Docker container start/stop/restart actions."""
|
||||||
|
|
||||||
|
def test_start_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test starting a container."""
|
||||||
|
container_id = "abc123"
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
f"/api/docker/containers/{container_id}/start",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "message" in data or "status" in data
|
||||||
|
|
||||||
|
def test_stop_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test stopping a container."""
|
||||||
|
container_id = "abc123"
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
f"/api/docker/containers/{container_id}/stop",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_restart_container_success(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test restarting a container."""
|
||||||
|
container_id = "abc123"
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
f"/api/docker/containers/{container_id}/restart",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_container_action_without_auth(self, test_app):
|
||||||
|
"""Test container action without authentication."""
|
||||||
|
response = test_app.post("/api/docker/containers/abc123/start")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_container_action_invalid_action(self, test_app, admin_headers):
|
||||||
|
"""Test container action with invalid action."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/docker/containers/abc123/invalid_action",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerContainerLogs:
|
||||||
|
"""Test Docker container logs retrieval."""
|
||||||
|
|
||||||
|
def test_get_container_logs_success(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test getting container logs."""
|
||||||
|
container_id = "abc123"
|
||||||
|
|
||||||
|
response = test_app.get(
|
||||||
|
f"/api/docker/containers/{container_id}/logs",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "logs" in data or isinstance(data, str)
|
||||||
|
|
||||||
|
def test_get_container_logs_with_tail(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test getting container logs with tail limit."""
|
||||||
|
container_id = "abc123"
|
||||||
|
|
||||||
|
response = test_app.get(
|
||||||
|
f"/api/docker/containers/{container_id}/logs?tail=100",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_get_container_logs_without_auth(self, test_app):
|
||||||
|
"""Test getting logs without authentication."""
|
||||||
|
response = test_app.get("/api/docker/containers/abc123/logs")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_get_container_logs_nonexistent(self, test_app, admin_headers, mock_docker_client):
|
||||||
|
"""Test getting logs for nonexistent container."""
|
||||||
|
# Mock container not found
|
||||||
|
mock_docker_client.containers.get.side_effect = Exception("Container not found")
|
||||||
|
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/docker/containers/nonexistent/logs",
|
||||||
|
headers=admin_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should handle error gracefully
|
||||||
|
assert response.status_code in [404, 500]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerPermissions:
|
||||||
|
"""Test Docker operation permissions."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def member_token(self, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
|
"""Create token for member role."""
|
||||||
|
from auth import create_access_token
|
||||||
|
from datetime import timedelta
|
||||||
|
return create_access_token(
|
||||||
|
data={"sub": "memberuser", "role": "member"},
|
||||||
|
expires_delta=timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def member_headers(self, member_token):
|
||||||
|
"""Headers with member access token."""
|
||||||
|
return {"Authorization": f"Bearer {member_token}"}
|
||||||
|
|
||||||
|
def test_member_can_list_containers(self, test_app, member_headers):
|
||||||
|
"""Test that member can list containers."""
|
||||||
|
response = test_app.get("/api/docker/containers", headers=member_headers)
|
||||||
|
|
||||||
|
# Members should be able to view containers
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_member_cannot_start_container(self, test_app, member_headers):
|
||||||
|
"""Test that member cannot start containers (admin only)."""
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/docker/containers/abc123/start",
|
||||||
|
headers=member_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be forbidden for non-admin
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_viewer_can_list_containers(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
|
"""Test that viewer can list containers if they have docker page access."""
|
||||||
|
from auth import create_access_token
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
viewer_token = create_access_token(
|
||||||
|
data={"sub": "vieweruser", "role": "viewer"},
|
||||||
|
expires_delta=timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {viewer_token}"}
|
||||||
|
|
||||||
|
response = test_app.get("/api/docker/containers", headers=headers)
|
||||||
|
|
||||||
|
# Viewer may not have docker page access by default
|
||||||
|
assert response.status_code in [200, 403]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for file operations.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileBrowse:
|
||||||
|
"""Test file browsing."""
|
||||||
|
|
||||||
|
def test_browse_root(self, test_app, admin_headers, temp_volume_root):
|
||||||
|
"""Test browsing root directory."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/browse",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "files" in data or isinstance(data, list)
|
||||||
|
|
||||||
|
def test_browse_without_auth(self, test_app):
|
||||||
|
"""Test browsing without authentication."""
|
||||||
|
response = test_app.get("/api/files/browse", params={"path": "/"})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_browse_blocked_directory(self, test_app, admin_headers):
|
||||||
|
"""Test browsing blocked directory."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/browse",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/@appstore"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be forbidden
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
def test_browse_path_traversal_attempt(self, test_app, admin_headers):
|
||||||
|
"""Test path traversal protection."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/browse",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/../../../etc"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be forbidden
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileUpload:
|
||||||
|
"""Test file upload."""
|
||||||
|
|
||||||
|
def test_upload_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||||
|
"""Test uploading a file."""
|
||||||
|
test_content = b"test file content"
|
||||||
|
files = {"file": ("test.txt", test_content, "text/plain")}
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
headers=admin_headers,
|
||||||
|
files=files,
|
||||||
|
data={"path": "/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in [200, 201]
|
||||||
|
|
||||||
|
def test_upload_without_auth(self, test_app):
|
||||||
|
"""Test upload without authentication."""
|
||||||
|
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
files=files,
|
||||||
|
data={"path": "/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
|
"""Test that non-admin cannot upload."""
|
||||||
|
from auth import create_access_token
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
member_token = create_access_token(
|
||||||
|
data={"sub": "memberuser", "role": "member"},
|
||||||
|
expires_delta=timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {member_token}"}
|
||||||
|
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||||
|
|
||||||
|
response = test_app.post(
|
||||||
|
"/api/files/upload",
|
||||||
|
headers=headers,
|
||||||
|
files=files,
|
||||||
|
data={"path": "/"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload should be admin-only
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileDelete:
|
||||||
|
"""Test file deletion."""
|
||||||
|
|
||||||
|
def test_delete_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||||
|
"""Test deleting a file."""
|
||||||
|
# Create a test file first
|
||||||
|
test_file = os.path.join(temp_volume_root, "test_delete.txt")
|
||||||
|
with open(test_file, "w") as f:
|
||||||
|
f.write("test content")
|
||||||
|
|
||||||
|
response = test_app.delete(
|
||||||
|
"/api/files/delete",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/test_delete.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code in [200, 204]
|
||||||
|
|
||||||
|
def test_delete_without_auth(self, test_app):
|
||||||
|
"""Test delete without authentication."""
|
||||||
|
response = test_app.delete(
|
||||||
|
"/api/files/delete",
|
||||||
|
params={"path": "/test.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
|
"""Test that non-admin cannot delete."""
|
||||||
|
from auth import create_access_token
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
member_token = create_access_token(
|
||||||
|
data={"sub": "memberuser", "role": "member"},
|
||||||
|
expires_delta=timedelta(minutes=30)
|
||||||
|
)
|
||||||
|
headers = {"Authorization": f"Bearer {member_token}"}
|
||||||
|
|
||||||
|
response = test_app.delete(
|
||||||
|
"/api/files/delete",
|
||||||
|
headers=headers,
|
||||||
|
params={"path": "/test.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete should be admin-only
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileDownload:
|
||||||
|
"""Test file download."""
|
||||||
|
|
||||||
|
def test_download_file_success(self, test_app, admin_headers, temp_volume_root):
|
||||||
|
"""Test downloading a file."""
|
||||||
|
# Create a test file
|
||||||
|
test_file = os.path.join(temp_volume_root, "test_download.txt")
|
||||||
|
test_content = "test download content"
|
||||||
|
with open(test_file, "w") as f:
|
||||||
|
f.write(test_content)
|
||||||
|
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/download",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/test_download.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert test_content in response.text or response.content == test_content.encode()
|
||||||
|
|
||||||
|
def test_download_without_auth(self, test_app):
|
||||||
|
"""Test download without authentication."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/download",
|
||||||
|
params={"path": "/test.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_download_nonexistent_file(self, test_app, admin_headers):
|
||||||
|
"""Test downloading nonexistent file."""
|
||||||
|
response = test_app.get(
|
||||||
|
"/api/files/download",
|
||||||
|
headers=admin_headers,
|
||||||
|
params={"path": "/nonexistent.txt"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Unit tests
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import jwt
|
||||||
|
import pyotp
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from auth import (
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
user_from_access_token,
|
||||||
|
verify_totp,
|
||||||
|
load_totp_secret,
|
||||||
|
save_totp_secret,
|
||||||
|
load_password_hash,
|
||||||
|
save_password_hash,
|
||||||
|
increment_token_version,
|
||||||
|
verify_token_version,
|
||||||
|
_encrypt,
|
||||||
|
_decrypt,
|
||||||
|
load_passkey_credentials,
|
||||||
|
save_passkey_credential,
|
||||||
|
clear_passkey_credentials,
|
||||||
|
)
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordHashing:
|
||||||
|
"""Test password hashing and verification."""
|
||||||
|
|
||||||
|
def test_hash_and_verify_password(self):
|
||||||
|
"""Test that password hashing and verification works."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hashed != password
|
||||||
|
assert verify_password(password, hashed)
|
||||||
|
|
||||||
|
def test_verify_wrong_password(self):
|
||||||
|
"""Test that wrong password fails verification."""
|
||||||
|
password = "correct_password"
|
||||||
|
wrong_password = "wrong_password"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert not verify_password(wrong_password, hashed)
|
||||||
|
|
||||||
|
def test_different_hashes_for_same_password(self):
|
||||||
|
"""Test that same password produces different hashes (salt)."""
|
||||||
|
password = "test_password"
|
||||||
|
hash1 = get_password_hash(password)
|
||||||
|
hash2 = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hash1 != hash2
|
||||||
|
assert verify_password(password, hash1)
|
||||||
|
assert verify_password(password, hash2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestJWTTokens:
|
||||||
|
"""Test JWT token creation and validation."""
|
||||||
|
|
||||||
|
def test_create_access_token(self, mock_config):
|
||||||
|
"""Test access token creation."""
|
||||||
|
data = {"sub": "testuser", "role": "admin"}
|
||||||
|
token = create_access_token(data)
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||||
|
assert payload["sub"] == "testuser"
|
||||||
|
assert payload["role"] == "admin"
|
||||||
|
assert payload["type"] == "access"
|
||||||
|
assert "exp" in payload
|
||||||
|
|
||||||
|
def test_create_access_token_with_custom_expiry(self, mock_config):
|
||||||
|
"""Test access token with custom expiration."""
|
||||||
|
data = {"sub": "testuser"}
|
||||||
|
expires_delta = timedelta(minutes=60)
|
||||||
|
token = create_access_token(data, expires_delta)
|
||||||
|
|
||||||
|
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||||
|
exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Should expire in approximately 60 minutes
|
||||||
|
time_diff = (exp_time - now).total_seconds()
|
||||||
|
assert 3500 < time_diff < 3700 # ~60 minutes with some tolerance
|
||||||
|
|
||||||
|
def test_create_refresh_token(self, mock_config, temp_auth_file):
|
||||||
|
"""Test refresh token creation."""
|
||||||
|
data = {"sub": "testuser", "role": "member"}
|
||||||
|
token = create_refresh_token(data)
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||||
|
assert payload["sub"] == "testuser"
|
||||||
|
assert payload["role"] == "member"
|
||||||
|
assert payload["type"] == "refresh"
|
||||||
|
assert "tv" in payload # token version
|
||||||
|
assert "exp" in payload
|
||||||
|
|
||||||
|
def test_user_from_valid_access_token(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test extracting user from valid access token."""
|
||||||
|
token = create_access_token({"sub": "testuser", "role": "admin"})
|
||||||
|
user = user_from_access_token(token)
|
||||||
|
|
||||||
|
assert user.username == "testuser"
|
||||||
|
assert user.role == "admin"
|
||||||
|
assert user.pages == "*"
|
||||||
|
assert user.sidebar_links == "*"
|
||||||
|
|
||||||
|
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test that expired token raises HTTPException."""
|
||||||
|
token = create_access_token(
|
||||||
|
{"sub": "testuser", "role": "admin"},
|
||||||
|
expires_delta=timedelta(seconds=-1)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
user_from_access_token(token)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
def test_user_from_invalid_token(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test that invalid token raises HTTPException."""
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
user_from_access_token("invalid.token.here")
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
def test_user_from_refresh_token_fails(self, mock_config, temp_rbac_file, temp_auth_file):
|
||||||
|
"""Test that refresh token cannot be used as access token."""
|
||||||
|
token = create_refresh_token({"sub": "testuser", "role": "admin"})
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
user_from_access_token(token)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
def test_user_from_token_without_username(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test that token without username fails."""
|
||||||
|
# Create token without 'sub' field
|
||||||
|
token = jwt.encode(
|
||||||
|
{"role": "admin", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)},
|
||||||
|
mock_config.SECRET_KEY,
|
||||||
|
algorithm=mock_config.ALGORITHM
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
user_from_access_token(token)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestEncryption:
|
||||||
|
"""Test encryption and decryption functions."""
|
||||||
|
|
||||||
|
def test_encrypt_decrypt(self, mock_config):
|
||||||
|
"""Test basic encryption and decryption."""
|
||||||
|
plaintext = "my-secret-totp-key"
|
||||||
|
encrypted = _encrypt(plaintext)
|
||||||
|
|
||||||
|
assert encrypted != plaintext
|
||||||
|
assert encrypted != ""
|
||||||
|
|
||||||
|
decrypted = _decrypt(encrypted)
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
def test_encrypt_empty_string(self, mock_config):
|
||||||
|
"""Test encrypting empty string."""
|
||||||
|
encrypted = _encrypt("")
|
||||||
|
assert encrypted == ""
|
||||||
|
|
||||||
|
decrypted = _decrypt("")
|
||||||
|
assert decrypted == ""
|
||||||
|
|
||||||
|
def test_decrypt_invalid_ciphertext(self, mock_config):
|
||||||
|
"""Test decrypting invalid ciphertext returns original."""
|
||||||
|
invalid = "not-a-valid-encrypted-string"
|
||||||
|
decrypted = _decrypt(invalid)
|
||||||
|
|
||||||
|
# Should fallback to returning the original string
|
||||||
|
assert decrypted == invalid
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_unicode(self, mock_config):
|
||||||
|
"""Test encryption with unicode characters."""
|
||||||
|
plaintext = "测试密钥🔐"
|
||||||
|
encrypted = _encrypt(plaintext)
|
||||||
|
decrypted = _decrypt(encrypted)
|
||||||
|
|
||||||
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
|
class TestTOTP:
|
||||||
|
"""Test TOTP functionality."""
|
||||||
|
|
||||||
|
def test_save_and_load_totp_secret(self, mock_config, temp_auth_file):
|
||||||
|
"""Test saving and loading TOTP secret."""
|
||||||
|
secret = "JBSWY3DPEHPK3PXP"
|
||||||
|
save_totp_secret(secret)
|
||||||
|
|
||||||
|
loaded = load_totp_secret()
|
||||||
|
assert loaded == secret
|
||||||
|
|
||||||
|
def test_load_totp_secret_from_env(self, mock_config, temp_auth_file, monkeypatch):
|
||||||
|
"""Test loading TOTP secret from environment variable."""
|
||||||
|
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||||
|
import importlib
|
||||||
|
import config
|
||||||
|
importlib.reload(config)
|
||||||
|
|
||||||
|
# When no secret in file, should return env var
|
||||||
|
loaded = load_totp_secret()
|
||||||
|
assert loaded == "ENV_SECRET_KEY"
|
||||||
|
|
||||||
|
def test_verify_totp_valid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test verifying valid TOTP code."""
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
totp = pyotp.TOTP(sample_totp_secret)
|
||||||
|
valid_code = totp.now()
|
||||||
|
|
||||||
|
assert verify_totp(valid_code)
|
||||||
|
|
||||||
|
def test_verify_totp_invalid_code(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test verifying invalid TOTP code."""
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
|
assert not verify_totp("000000")
|
||||||
|
|
||||||
|
def test_verify_totp_no_secret_enabled(self, mock_config, temp_auth_file):
|
||||||
|
"""Test that TOTP verification passes when 2FA not enabled."""
|
||||||
|
# No secret saved, should return True (2FA disabled)
|
||||||
|
assert verify_totp("any_code")
|
||||||
|
|
||||||
|
def test_verify_totp_replay_protection(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
|
"""Test that same TOTP code cannot be used twice."""
|
||||||
|
save_totp_secret(sample_totp_secret)
|
||||||
|
totp = pyotp.TOTP(sample_totp_secret)
|
||||||
|
valid_code = totp.now()
|
||||||
|
|
||||||
|
# First use should succeed
|
||||||
|
assert verify_totp(valid_code)
|
||||||
|
|
||||||
|
# Second use of same code should fail (replay protection)
|
||||||
|
assert not verify_totp(valid_code)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordPersistence:
|
||||||
|
"""Test password hash persistence."""
|
||||||
|
|
||||||
|
def test_save_and_load_password_hash(self, mock_config, temp_auth_file):
|
||||||
|
"""Test saving and loading password hash."""
|
||||||
|
hashed = get_password_hash("new_password")
|
||||||
|
save_password_hash(hashed)
|
||||||
|
|
||||||
|
loaded = load_password_hash()
|
||||||
|
assert loaded == hashed
|
||||||
|
|
||||||
|
def test_load_password_hash_from_env(self, mock_config, temp_auth_file):
|
||||||
|
"""Test loading password hash from environment variable."""
|
||||||
|
# When no hash in file, should return env var
|
||||||
|
loaded = load_password_hash()
|
||||||
|
assert loaded == mock_config.ADMIN_PASSWORD_HASH
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasskeyCredentials:
|
||||||
|
"""Test passkey credential management."""
|
||||||
|
|
||||||
|
def test_save_and_load_passkey_credentials(self, mock_config, temp_auth_file):
|
||||||
|
"""Test saving and loading passkey credentials."""
|
||||||
|
cred1 = {"id": "cred1", "public_key": "key1"}
|
||||||
|
cred2 = {"id": "cred2", "public_key": "key2"}
|
||||||
|
|
||||||
|
save_passkey_credential(cred1)
|
||||||
|
save_passkey_credential(cred2)
|
||||||
|
|
||||||
|
creds = load_passkey_credentials()
|
||||||
|
assert len(creds) == 2
|
||||||
|
assert cred1 in creds
|
||||||
|
assert cred2 in creds
|
||||||
|
|
||||||
|
def test_clear_passkey_credentials(self, mock_config, temp_auth_file):
|
||||||
|
"""Test clearing all passkey credentials."""
|
||||||
|
save_passkey_credential({"id": "cred1"})
|
||||||
|
save_passkey_credential({"id": "cred2"})
|
||||||
|
|
||||||
|
clear_passkey_credentials()
|
||||||
|
|
||||||
|
creds = load_passkey_credentials()
|
||||||
|
assert len(creds) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenVersion:
|
||||||
|
"""Test token version management for refresh token rotation."""
|
||||||
|
|
||||||
|
def test_increment_token_version(self, mock_config, temp_auth_file):
|
||||||
|
"""Test incrementing token version."""
|
||||||
|
v1 = increment_token_version()
|
||||||
|
assert v1 == 1
|
||||||
|
|
||||||
|
v2 = increment_token_version()
|
||||||
|
assert v2 == 2
|
||||||
|
|
||||||
|
v3 = increment_token_version()
|
||||||
|
assert v3 == 3
|
||||||
|
|
||||||
|
def test_verify_token_version(self, mock_config, temp_auth_file):
|
||||||
|
"""Test verifying token version."""
|
||||||
|
v1 = increment_token_version()
|
||||||
|
assert verify_token_version(v1)
|
||||||
|
|
||||||
|
v2 = increment_token_version()
|
||||||
|
assert verify_token_version(v2)
|
||||||
|
assert not verify_token_version(v1) # Old version should fail
|
||||||
|
|
||||||
|
def test_token_version_in_refresh_token(self, mock_config, temp_auth_file):
|
||||||
|
"""Test that refresh token includes current token version."""
|
||||||
|
current_version = increment_token_version()
|
||||||
|
token = create_refresh_token({"sub": "testuser"})
|
||||||
|
|
||||||
|
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||||
|
assert payload["tv"] == current_version
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for config.py - IP parsing, environment validation.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from config import (
|
||||||
|
_parse_ip,
|
||||||
|
_ip_matches_ranges,
|
||||||
|
is_lan_ip,
|
||||||
|
is_tailscale_ip,
|
||||||
|
is_trusted_proxy,
|
||||||
|
get_forwarded_client_ip,
|
||||||
|
get_client_ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseIP:
|
||||||
|
"""Test IP address parsing."""
|
||||||
|
|
||||||
|
def test_parse_valid_ipv4(self):
|
||||||
|
"""Test parsing valid IPv4 address."""
|
||||||
|
ip = _parse_ip("192.168.1.1")
|
||||||
|
assert ip is not None
|
||||||
|
assert str(ip) == "192.168.1.1"
|
||||||
|
|
||||||
|
def test_parse_valid_ipv6(self):
|
||||||
|
"""Test parsing valid IPv6 address."""
|
||||||
|
ip = _parse_ip("2001:db8::1")
|
||||||
|
assert ip is not None
|
||||||
|
assert str(ip) == "2001:db8::1"
|
||||||
|
|
||||||
|
def test_parse_localhost_ipv4(self):
|
||||||
|
"""Test parsing localhost IPv4."""
|
||||||
|
ip = _parse_ip("127.0.0.1")
|
||||||
|
assert ip is not None
|
||||||
|
assert str(ip) == "127.0.0.1"
|
||||||
|
|
||||||
|
def test_parse_localhost_ipv6(self):
|
||||||
|
"""Test parsing localhost IPv6."""
|
||||||
|
ip = _parse_ip("::1")
|
||||||
|
assert ip is not None
|
||||||
|
assert str(ip) == "::1"
|
||||||
|
|
||||||
|
def test_parse_invalid_ip(self):
|
||||||
|
"""Test parsing invalid IP returns None."""
|
||||||
|
assert _parse_ip("not.an.ip.address") is None
|
||||||
|
assert _parse_ip("999.999.999.999") is None
|
||||||
|
assert _parse_ip("") is None
|
||||||
|
assert _parse_ip(" ") is None
|
||||||
|
|
||||||
|
def test_parse_ip_with_whitespace(self):
|
||||||
|
"""Test parsing IP with surrounding whitespace."""
|
||||||
|
ip = _parse_ip(" 192.168.1.1 ")
|
||||||
|
assert ip is not None
|
||||||
|
assert str(ip) == "192.168.1.1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestIPMatchesRanges:
|
||||||
|
"""Test IP range matching."""
|
||||||
|
|
||||||
|
def test_match_single_ip(self):
|
||||||
|
"""Test matching against single IP."""
|
||||||
|
assert _ip_matches_ranges("192.168.1.1", ["192.168.1.1"])
|
||||||
|
assert not _ip_matches_ranges("192.168.1.2", ["192.168.1.1"])
|
||||||
|
|
||||||
|
def test_match_cidr_range(self):
|
||||||
|
"""Test matching against CIDR range."""
|
||||||
|
ranges = ["192.168.1.0/24"]
|
||||||
|
assert _ip_matches_ranges("192.168.1.1", ranges)
|
||||||
|
assert _ip_matches_ranges("192.168.1.100", ranges)
|
||||||
|
assert _ip_matches_ranges("192.168.1.254", ranges)
|
||||||
|
assert not _ip_matches_ranges("192.168.2.1", ranges)
|
||||||
|
|
||||||
|
def test_match_multiple_ranges(self):
|
||||||
|
"""Test matching against multiple ranges."""
|
||||||
|
ranges = ["192.168.1.0/24", "10.0.0.0/8", "172.16.0.1"]
|
||||||
|
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||||
|
assert _ip_matches_ranges("10.5.10.20", ranges)
|
||||||
|
assert _ip_matches_ranges("172.16.0.1", ranges)
|
||||||
|
assert not _ip_matches_ranges("8.8.8.8", ranges)
|
||||||
|
|
||||||
|
def test_match_ipv6_range(self):
|
||||||
|
"""Test matching IPv6 against range."""
|
||||||
|
ranges = ["2001:db8::/32"]
|
||||||
|
assert _ip_matches_ranges("2001:db8::1", ranges)
|
||||||
|
assert _ip_matches_ranges("2001:db8:1234::5678", ranges)
|
||||||
|
assert not _ip_matches_ranges("2001:db9::1", ranges)
|
||||||
|
|
||||||
|
def test_match_invalid_ip(self):
|
||||||
|
"""Test that invalid IP returns False."""
|
||||||
|
assert not _ip_matches_ranges("invalid", ["192.168.1.0/24"])
|
||||||
|
assert not _ip_matches_ranges("", ["192.168.1.0/24"])
|
||||||
|
|
||||||
|
def test_match_empty_ranges(self):
|
||||||
|
"""Test matching against empty ranges."""
|
||||||
|
assert not _ip_matches_ranges("192.168.1.1", [])
|
||||||
|
|
||||||
|
def test_match_with_whitespace_in_ranges(self):
|
||||||
|
"""Test matching with whitespace in range strings."""
|
||||||
|
ranges = [" 192.168.1.0/24 ", " 10.0.0.1 "]
|
||||||
|
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||||
|
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||||
|
|
||||||
|
def test_match_with_empty_string_in_ranges(self):
|
||||||
|
"""Test that empty strings in ranges are skipped."""
|
||||||
|
ranges = ["192.168.1.0/24", "", " ", "10.0.0.1"]
|
||||||
|
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||||
|
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||||
|
|
||||||
|
def test_match_with_invalid_range(self):
|
||||||
|
"""Test that invalid ranges are skipped."""
|
||||||
|
ranges = ["192.168.1.0/24", "invalid-range", "10.0.0.1"]
|
||||||
|
assert _ip_matches_ranges("192.168.1.50", ranges)
|
||||||
|
assert _ip_matches_ranges("10.0.0.1", ranges)
|
||||||
|
assert not _ip_matches_ranges("8.8.8.8", ranges)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsLanIP:
|
||||||
|
"""Test LAN IP detection."""
|
||||||
|
|
||||||
|
def test_is_lan_ip_default_range(self, mock_config):
|
||||||
|
"""Test LAN IP detection with default range."""
|
||||||
|
# Default is 192.168.31.0/24
|
||||||
|
assert is_lan_ip("192.168.31.1")
|
||||||
|
assert is_lan_ip("192.168.31.100")
|
||||||
|
assert is_lan_ip("192.168.31.254")
|
||||||
|
assert not is_lan_ip("192.168.30.1")
|
||||||
|
assert not is_lan_ip("192.168.32.1")
|
||||||
|
|
||||||
|
def test_is_lan_ip_not_tailscale(self, mock_config):
|
||||||
|
"""Test that Tailscale IPs are not considered LAN."""
|
||||||
|
assert not is_lan_ip("100.64.0.1")
|
||||||
|
assert not is_lan_ip("100.78.131.124")
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTailscaleIP:
|
||||||
|
"""Test Tailscale IP detection."""
|
||||||
|
|
||||||
|
def test_is_tailscale_ip_valid(self):
|
||||||
|
"""Test Tailscale IP detection."""
|
||||||
|
assert is_tailscale_ip("100.64.0.1")
|
||||||
|
assert is_tailscale_ip("100.78.131.124")
|
||||||
|
assert is_tailscale_ip("100.100.100.100")
|
||||||
|
assert is_tailscale_ip("100.127.255.254")
|
||||||
|
|
||||||
|
def test_is_tailscale_ip_invalid(self):
|
||||||
|
"""Test non-Tailscale IPs."""
|
||||||
|
assert not is_tailscale_ip("100.63.255.255") # Just below range
|
||||||
|
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
||||||
|
assert not is_tailscale_ip("192.168.1.1")
|
||||||
|
assert not is_tailscale_ip("10.0.0.1")
|
||||||
|
|
||||||
|
def test_is_tailscale_ip_boundary(self):
|
||||||
|
"""Test Tailscale IP range boundaries."""
|
||||||
|
# 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255
|
||||||
|
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||||
|
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTrustedProxy:
|
||||||
|
"""Test trusted proxy detection."""
|
||||||
|
|
||||||
|
def test_is_trusted_proxy_default(self, mock_config):
|
||||||
|
"""Test trusted proxy detection with default config."""
|
||||||
|
# Default: 127.0.0.1,::1,172.21.0.1
|
||||||
|
assert is_trusted_proxy("127.0.0.1")
|
||||||
|
assert is_trusted_proxy("::1")
|
||||||
|
assert is_trusted_proxy("172.21.0.1")
|
||||||
|
assert not is_trusted_proxy("192.168.1.1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetForwardedClientIP:
|
||||||
|
"""Test forwarded client IP extraction."""
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_from_trusted_proxy(self, mock_config):
|
||||||
|
"""Test extracting forwarded IP from trusted proxy."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "127.0.0.1" # Trusted proxy
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip == "203.0.113.1"
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_from_untrusted_proxy(self, mock_config):
|
||||||
|
"""Test that untrusted proxy returns None."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "8.8.8.8" # Not trusted
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_multiple_hops(self, mock_config):
|
||||||
|
"""Test extracting first IP from multiple forwarded IPs."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "127.0.0.1"
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1, 198.51.100.1, 192.0.2.1"}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip == "203.0.113.1"
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_no_header(self, mock_config):
|
||||||
|
"""Test when x-forwarded-for header is missing."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "127.0.0.1"
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_empty_header(self, mock_config):
|
||||||
|
"""Test when x-forwarded-for header is empty."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "127.0.0.1"
|
||||||
|
request.headers = {"x-forwarded-for": ""}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
def test_get_forwarded_ip_no_client(self, mock_config):
|
||||||
|
"""Test when request has no client."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = None
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||||
|
|
||||||
|
ip = get_forwarded_client_ip(request)
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetClientIP:
|
||||||
|
"""Test client IP extraction."""
|
||||||
|
|
||||||
|
def test_get_client_ip_direct(self, mock_config):
|
||||||
|
"""Test getting client IP from direct connection."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "192.168.1.100"
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
assert ip == "192.168.1.100"
|
||||||
|
|
||||||
|
def test_get_client_ip_via_trusted_proxy(self, mock_config):
|
||||||
|
"""Test getting client IP via trusted proxy."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "127.0.0.1"
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||||
|
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
assert ip == "203.0.113.1"
|
||||||
|
|
||||||
|
def test_get_client_ip_via_untrusted_proxy(self, mock_config):
|
||||||
|
"""Test getting client IP via untrusted proxy falls back to direct."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = Mock()
|
||||||
|
request.client.host = "8.8.8.8"
|
||||||
|
request.headers = {"x-forwarded-for": "203.0.113.1"}
|
||||||
|
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
assert ip == "8.8.8.8"
|
||||||
|
|
||||||
|
def test_get_client_ip_no_client(self, mock_config):
|
||||||
|
"""Test getting client IP when request has no client."""
|
||||||
|
request = Mock()
|
||||||
|
request.client = None
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
assert ip == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigValidation:
|
||||||
|
"""Test configuration validation."""
|
||||||
|
|
||||||
|
def test_secret_key_validation(self, monkeypatch):
|
||||||
|
"""Test that short SECRET_KEY raises error."""
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "short")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||||
|
import config
|
||||||
|
import importlib
|
||||||
|
importlib.reload(config)
|
||||||
|
|
||||||
|
def test_secret_key_empty(self, monkeypatch):
|
||||||
|
"""Test that empty SECRET_KEY raises error."""
|
||||||
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||||
|
import config
|
||||||
|
import importlib
|
||||||
|
importlib.reload(config)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for rbac.py - Role-based access control.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from rbac import (
|
||||||
|
load_rbac,
|
||||||
|
save_rbac,
|
||||||
|
update_rbac,
|
||||||
|
resolve_role,
|
||||||
|
get_pages,
|
||||||
|
get_sidebar_links,
|
||||||
|
get_sidebar_order,
|
||||||
|
DEFAULT_RBAC,
|
||||||
|
ROLE_GROUP_MAP,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadRBAC:
|
||||||
|
"""Test loading RBAC configuration."""
|
||||||
|
|
||||||
|
def test_load_rbac_from_file(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test loading RBAC from existing file."""
|
||||||
|
rbac = load_rbac()
|
||||||
|
|
||||||
|
assert "role_defaults" in rbac
|
||||||
|
assert "user_overrides" in rbac
|
||||||
|
assert "admin" in rbac["role_defaults"]
|
||||||
|
assert "member" in rbac["role_defaults"]
|
||||||
|
assert "viewer" in rbac["role_defaults"]
|
||||||
|
|
||||||
|
def test_load_rbac_missing_file(self, mock_config, temp_volume_root):
|
||||||
|
"""Test loading RBAC when file doesn't exist returns defaults."""
|
||||||
|
# Don't create rbac file
|
||||||
|
rbac = load_rbac()
|
||||||
|
|
||||||
|
assert rbac == DEFAULT_RBAC
|
||||||
|
|
||||||
|
def test_load_rbac_corrupted_file(self, mock_config, temp_volume_root):
|
||||||
|
"""Test loading RBAC with corrupted file returns defaults."""
|
||||||
|
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||||
|
os.makedirs(os.path.dirname(rbac_file), exist_ok=True)
|
||||||
|
|
||||||
|
# Write invalid JSON
|
||||||
|
with open(rbac_file, "w") as f:
|
||||||
|
f.write("invalid json {{{")
|
||||||
|
|
||||||
|
rbac = load_rbac()
|
||||||
|
assert rbac == DEFAULT_RBAC
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveRBAC:
|
||||||
|
"""Test saving RBAC configuration."""
|
||||||
|
|
||||||
|
def test_save_rbac(self, mock_config, temp_volume_root):
|
||||||
|
"""Test saving RBAC configuration."""
|
||||||
|
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||||
|
test_data = {
|
||||||
|
"role_defaults": {"admin": {"pages": "*"}},
|
||||||
|
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
||||||
|
}
|
||||||
|
|
||||||
|
save_rbac(test_data)
|
||||||
|
|
||||||
|
assert os.path.exists(rbac_file)
|
||||||
|
with open(rbac_file) as f:
|
||||||
|
loaded = json.load(f)
|
||||||
|
assert loaded == test_data
|
||||||
|
|
||||||
|
def test_save_rbac_creates_directory(self, mock_config, temp_volume_root):
|
||||||
|
"""Test that save_rbac creates directory if it doesn't exist."""
|
||||||
|
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||||
|
|
||||||
|
# Remove directory
|
||||||
|
import shutil
|
||||||
|
if os.path.exists(os.path.dirname(rbac_file)):
|
||||||
|
shutil.rmtree(os.path.dirname(rbac_file))
|
||||||
|
|
||||||
|
test_data = {"role_defaults": {}, "user_overrides": {}}
|
||||||
|
save_rbac(test_data)
|
||||||
|
|
||||||
|
assert os.path.exists(rbac_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateRBAC:
|
||||||
|
"""Test atomic RBAC updates."""
|
||||||
|
|
||||||
|
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test updating RBAC with mutator function."""
|
||||||
|
def add_user_override(data):
|
||||||
|
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
result = update_rbac(add_user_override)
|
||||||
|
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
# Verify changes persisted
|
||||||
|
rbac = load_rbac()
|
||||||
|
assert "newuser" in rbac["user_overrides"]
|
||||||
|
assert rbac["user_overrides"]["newuser"]["pages"] == ["dashboard", "docker"]
|
||||||
|
|
||||||
|
def test_update_rbac_thread_safe(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test that update_rbac is thread-safe."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def increment_counter(data):
|
||||||
|
current = data.get("counter", 0)
|
||||||
|
data["counter"] = current + 1
|
||||||
|
return data["counter"]
|
||||||
|
|
||||||
|
# Run multiple updates concurrently
|
||||||
|
threads = []
|
||||||
|
for _ in range(10):
|
||||||
|
t = threading.Thread(target=lambda: results.append(update_rbac(increment_counter)))
|
||||||
|
threads.append(t)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
# Final counter should be 10
|
||||||
|
rbac = load_rbac()
|
||||||
|
assert rbac.get("counter") == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveRole:
|
||||||
|
"""Test role resolution from groups."""
|
||||||
|
|
||||||
|
def test_resolve_role_admin(self):
|
||||||
|
"""Test resolving admin role."""
|
||||||
|
groups = ["dashboard_admin", "other_group"]
|
||||||
|
role = resolve_role(groups)
|
||||||
|
assert role == "admin"
|
||||||
|
|
||||||
|
def test_resolve_role_member(self):
|
||||||
|
"""Test resolving member role."""
|
||||||
|
groups = ["dashboard_member"]
|
||||||
|
role = resolve_role(groups)
|
||||||
|
assert role == "member"
|
||||||
|
|
||||||
|
def test_resolve_role_viewer(self):
|
||||||
|
"""Test resolving viewer role."""
|
||||||
|
groups = ["dashboard_viewer", "unrelated_group"]
|
||||||
|
role = resolve_role(groups)
|
||||||
|
assert role == "viewer"
|
||||||
|
|
||||||
|
def test_resolve_role_no_match(self):
|
||||||
|
"""Test resolving role with no matching groups."""
|
||||||
|
groups = ["other_group", "another_group"]
|
||||||
|
role = resolve_role(groups)
|
||||||
|
assert role is None
|
||||||
|
|
||||||
|
def test_resolve_role_empty_groups(self):
|
||||||
|
"""Test resolving role with empty groups list."""
|
||||||
|
role = resolve_role([])
|
||||||
|
assert role is None
|
||||||
|
|
||||||
|
def test_resolve_role_priority(self):
|
||||||
|
"""Test that first matching group is used."""
|
||||||
|
groups = ["dashboard_admin", "dashboard_member"]
|
||||||
|
role = resolve_role(groups)
|
||||||
|
assert role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPages:
|
||||||
|
"""Test page access resolution."""
|
||||||
|
|
||||||
|
def test_get_pages_admin_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting pages for admin with default settings."""
|
||||||
|
pages = get_pages("testuser", "admin")
|
||||||
|
assert pages == "*"
|
||||||
|
|
||||||
|
def test_get_pages_member_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting pages for member with default settings."""
|
||||||
|
pages = get_pages("testuser", "member")
|
||||||
|
assert isinstance(pages, list)
|
||||||
|
assert "dashboard" in pages
|
||||||
|
assert "docker" in pages
|
||||||
|
|
||||||
|
def test_get_pages_viewer_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting pages for viewer with default settings."""
|
||||||
|
pages = get_pages("testuser", "viewer")
|
||||||
|
assert pages == ["dashboard"]
|
||||||
|
|
||||||
|
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting pages with user-specific override."""
|
||||||
|
# Add user override
|
||||||
|
def add_override(data):
|
||||||
|
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
||||||
|
|
||||||
|
update_rbac(add_override)
|
||||||
|
|
||||||
|
pages = get_pages("customuser", "admin")
|
||||||
|
assert pages == ["dashboard", "files"]
|
||||||
|
|
||||||
|
def test_get_pages_unknown_role(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting pages for unknown role returns empty list."""
|
||||||
|
pages = get_pages("testuser", "unknown_role")
|
||||||
|
assert pages == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSidebarLinks:
|
||||||
|
"""Test sidebar link access resolution."""
|
||||||
|
|
||||||
|
def test_get_sidebar_links_admin_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar links for admin."""
|
||||||
|
links = get_sidebar_links("testuser", "admin")
|
||||||
|
assert links == "*"
|
||||||
|
|
||||||
|
def test_get_sidebar_links_member_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar links for member."""
|
||||||
|
links = get_sidebar_links("testuser", "member")
|
||||||
|
assert links == "*"
|
||||||
|
|
||||||
|
def test_get_sidebar_links_viewer_default(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar links for viewer."""
|
||||||
|
links = get_sidebar_links("testuser", "viewer")
|
||||||
|
assert links == ["dashboard"]
|
||||||
|
|
||||||
|
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar links with user override."""
|
||||||
|
def add_override(data):
|
||||||
|
data["user_overrides"]["customuser"] = {
|
||||||
|
"pages": "*",
|
||||||
|
"sidebar_links": ["dashboard", "docker", "files"]
|
||||||
|
}
|
||||||
|
|
||||||
|
update_rbac(add_override)
|
||||||
|
|
||||||
|
links = get_sidebar_links("customuser", "admin")
|
||||||
|
assert links == ["dashboard", "docker", "files"]
|
||||||
|
|
||||||
|
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test that user without sidebar_links override gets role default."""
|
||||||
|
def add_override(data):
|
||||||
|
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||||
|
# No sidebar_links specified
|
||||||
|
|
||||||
|
update_rbac(add_override)
|
||||||
|
|
||||||
|
links = get_sidebar_links("customuser", "admin")
|
||||||
|
assert links == "*" # Should fall back to admin default
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSidebarOrder:
|
||||||
|
"""Test sidebar order retrieval."""
|
||||||
|
|
||||||
|
def test_get_sidebar_order_no_override(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar order when user has no override."""
|
||||||
|
order = get_sidebar_order("testuser")
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||||
|
"""Test getting sidebar order with user override."""
|
||||||
|
custom_order = {
|
||||||
|
"Main": ["dashboard", "docker"],
|
||||||
|
"Media": ["navidrome", "immich"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_override(data):
|
||||||
|
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||||
|
|
||||||
|
update_rbac(add_override)
|
||||||
|
|
||||||
|
order = get_sidebar_order("customuser")
|
||||||
|
assert order == custom_order
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultRBAC:
|
||||||
|
"""Test default RBAC configuration."""
|
||||||
|
|
||||||
|
def test_default_rbac_structure(self):
|
||||||
|
"""Test that DEFAULT_RBAC has correct structure."""
|
||||||
|
assert "role_defaults" in DEFAULT_RBAC
|
||||||
|
assert "user_overrides" in DEFAULT_RBAC
|
||||||
|
|
||||||
|
assert "admin" in DEFAULT_RBAC["role_defaults"]
|
||||||
|
assert "member" in DEFAULT_RBAC["role_defaults"]
|
||||||
|
assert "viewer" in DEFAULT_RBAC["role_defaults"]
|
||||||
|
|
||||||
|
def test_default_rbac_admin_permissions(self):
|
||||||
|
"""Test admin has full permissions by default."""
|
||||||
|
admin = DEFAULT_RBAC["role_defaults"]["admin"]
|
||||||
|
assert admin["pages"] == "*"
|
||||||
|
assert admin["sidebar_links"] == "*"
|
||||||
|
|
||||||
|
def test_default_rbac_member_permissions(self):
|
||||||
|
"""Test member has limited permissions."""
|
||||||
|
member = DEFAULT_RBAC["role_defaults"]["member"]
|
||||||
|
assert isinstance(member["pages"], list)
|
||||||
|
assert "dashboard" in member["pages"]
|
||||||
|
assert member["sidebar_links"] == "*"
|
||||||
|
|
||||||
|
def test_default_rbac_viewer_permissions(self):
|
||||||
|
"""Test viewer has minimal permissions."""
|
||||||
|
viewer = DEFAULT_RBAC["role_defaults"]["viewer"]
|
||||||
|
assert viewer["pages"] == ["dashboard"]
|
||||||
|
assert viewer["sidebar_links"] == ["dashboard"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleGroupMap:
|
||||||
|
"""Test role group mapping."""
|
||||||
|
|
||||||
|
def test_role_group_map_completeness(self):
|
||||||
|
"""Test that ROLE_GROUP_MAP has all expected mappings."""
|
||||||
|
assert "dashboard_admin" in ROLE_GROUP_MAP
|
||||||
|
assert "dashboard_member" in ROLE_GROUP_MAP
|
||||||
|
assert "dashboard_viewer" in ROLE_GROUP_MAP
|
||||||
|
|
||||||
|
assert ROLE_GROUP_MAP["dashboard_admin"] == "admin"
|
||||||
|
assert ROLE_GROUP_MAP["dashboard_member"] == "member"
|
||||||
|
assert ROLE_GROUP_MAP["dashboard_viewer"] == "viewer"
|
||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
||||||
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
|
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
|
||||||
|
- WEBAUTHN_RP_ID=jimmygan.com
|
||||||
|
- WEBAUTHN_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443,https://auth.jimmygan.com:8443
|
||||||
- LITELLM_URL=http://litellm:4005
|
- LITELLM_URL=http://litellm:4005
|
||||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ services:
|
|||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
|
||||||
- CORS_ORIGINS=https://nas.jimmygan.com
|
- CORS_ORIGINS=https://nas.jimmygan.com
|
||||||
|
- WEBAUTHN_RP_ID=jimmygan.com
|
||||||
|
- WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://auth.jimmygan.com:8443
|
||||||
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
|
||||||
- LITELLM_URL=http://litellm:4005
|
- LITELLM_URL=http://litellm:4005
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build"
|
"build": "vite build",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
|
"@testing-library/svelte": "^5.2.3",
|
||||||
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"vite": "^6.3.0",
|
"vite": "^6.3.0",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0"
|
"@tailwindcss/vite": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,22 @@
|
|||||||
import InfoEngine from "./routes/InfoEngine.svelte";
|
import InfoEngine from "./routes/InfoEngine.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
||||||
|
|
||||||
|
const KNOWN_PAGES = new Set([
|
||||||
|
"dashboard",
|
||||||
|
"docker",
|
||||||
|
"gitea",
|
||||||
|
"files",
|
||||||
|
"terminal",
|
||||||
|
"openclaw",
|
||||||
|
"chat-digest",
|
||||||
|
"settings",
|
||||||
|
"security",
|
||||||
|
"litellm",
|
||||||
|
"cc-connect",
|
||||||
|
"info-engine",
|
||||||
|
]);
|
||||||
|
|
||||||
let page = $state("dashboard");
|
let page = $state("dashboard");
|
||||||
let dark = $state(false);
|
let dark = $state(false);
|
||||||
@@ -27,6 +42,26 @@
|
|||||||
let sidebarOrder = $state(null);
|
let sidebarOrder = $state(null);
|
||||||
let orderSaveTimer = null;
|
let orderSaveTimer = null;
|
||||||
|
|
||||||
|
function getRequestedPage() {
|
||||||
|
const requestedPage = new URLSearchParams(window.location.search).get("page");
|
||||||
|
return KNOWN_PAGES.has(requestedPage) ? requestedPage : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canOpenPage(pageId) {
|
||||||
|
if (!pageId || !KNOWN_PAGES.has(pageId)) return false;
|
||||||
|
if (pageId === "settings") return userRole === "admin";
|
||||||
|
if (["litellm", "cc-connect", "info-engine"].includes(pageId)) return hasPageAccess("dashboard");
|
||||||
|
return hasPageAccess(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPageQuery(pageId) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (pageId === "dashboard") url.searchParams.delete("page");
|
||||||
|
else url.searchParams.set("page", pageId);
|
||||||
|
if (pageId !== "terminal") url.searchParams.delete("host");
|
||||||
|
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchUserInfo() {
|
async function fetchUserInfo() {
|
||||||
try {
|
try {
|
||||||
const data = await checkAuth();
|
const data = await checkAuth();
|
||||||
@@ -71,13 +106,14 @@
|
|||||||
|
|
||||||
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
|
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
|
||||||
try {
|
try {
|
||||||
const proxyRes = await fetch("/api/auth/proxy");
|
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
|
||||||
if (proxyRes.ok) {
|
if (proxyRes.ok) {
|
||||||
const data = await proxyRes.json();
|
const data = await proxyRes.json();
|
||||||
setToken(data.access_token);
|
setToken(data.access_token);
|
||||||
setRefreshToken(data.refresh_token);
|
|
||||||
authorized = true;
|
authorized = true;
|
||||||
await fetchUserInfo();
|
await fetchUserInfo();
|
||||||
|
const requestedPage = getRequestedPage();
|
||||||
|
if (canOpenPage(requestedPage)) page = requestedPage;
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,17 +121,19 @@
|
|||||||
// Proxy auth not available, fall through to regular auth
|
// Proxy auth not available, fall through to regular auth
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular token auth check
|
// Regular token auth check — always try refresh to get fresh cookies
|
||||||
const token = getToken();
|
const refreshed = await tryRefreshSession();
|
||||||
if (!token) {
|
if (!refreshed && !getToken()) {
|
||||||
loading = false;
|
loading = false;
|
||||||
authorized = false;
|
authorized = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchUserInfo();
|
await fetchUserInfo();
|
||||||
authorized = true;
|
authorized = true;
|
||||||
|
const requestedPage = getRequestedPage();
|
||||||
|
if (canOpenPage(requestedPage)) page = requestedPage;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Auth check failed:", e);
|
console.error("Auth check failed:", e);
|
||||||
setToken("");
|
setToken("");
|
||||||
@@ -105,6 +143,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!authorized || loading || !canOpenPage(page)) return;
|
||||||
|
syncPageQuery(page);
|
||||||
|
});
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
dark = !dark;
|
dark = !dark;
|
||||||
if (dark) {
|
if (dark) {
|
||||||
@@ -116,9 +159,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await logoutSession();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Logout failed:", e);
|
||||||
|
}
|
||||||
setToken("");
|
setToken("");
|
||||||
setRefreshToken("");
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
let token = localStorage.getItem("token") || "";
|
let token = "";
|
||||||
|
|
||||||
// Current user info populated after auth
|
// Current user info populated after auth
|
||||||
export let currentUser = { username: "", role: "", pages: [] };
|
export let currentUser = { username: "", role: "", pages: [] };
|
||||||
@@ -18,33 +18,51 @@ export function getToken() {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRefreshToken(t) {
|
// Refresh token is cookie-managed server-side.
|
||||||
if (t) localStorage.setItem("refresh_token", t);
|
export function setRefreshToken() {}
|
||||||
else localStorage.removeItem("refresh_token");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRefreshToken() {
|
|
||||||
return localStorage.getItem("refresh_token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshPromise = null;
|
let refreshPromise = null;
|
||||||
|
|
||||||
async function tryRefresh() {
|
function getLegacyRefreshToken() {
|
||||||
|
return localStorage.getItem("refresh_token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLegacyRefreshToken() {
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestRefresh(body) {
|
||||||
|
const r = await fetch(BASE + "/auth/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
if (!r.ok) return false;
|
||||||
|
const data = await r.json();
|
||||||
|
setToken(data.access_token || "");
|
||||||
|
return !!data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryRefreshSession() {
|
||||||
if (refreshPromise) return refreshPromise;
|
if (refreshPromise) return refreshPromise;
|
||||||
const rt = getRefreshToken();
|
|
||||||
if (!rt) return false;
|
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(BASE + "/auth/refresh", {
|
const cookieRefreshed = await requestRefresh();
|
||||||
method: "POST",
|
if (cookieRefreshed) {
|
||||||
headers: { "Content-Type": "application/json" },
|
clearLegacyRefreshToken();
|
||||||
body: JSON.stringify({ refresh_token: rt }),
|
return true;
|
||||||
});
|
}
|
||||||
if (!r.ok) return false;
|
|
||||||
const data = await r.json();
|
const legacyRefreshToken = getLegacyRefreshToken();
|
||||||
setToken(data.access_token);
|
if (!legacyRefreshToken) return false;
|
||||||
setRefreshToken(data.refresh_token);
|
const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
|
||||||
return true;
|
if (legacyRefreshed) {
|
||||||
|
clearLegacyRefreshToken();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
clearLegacyRefreshToken();
|
||||||
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -64,27 +82,29 @@ async function request(path, opts = {}) {
|
|||||||
delete opts.json;
|
delete opts.json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchOpts = { ...opts, headers };
|
||||||
|
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let r = await fetch(BASE + path, { ...opts, headers });
|
let r = await fetch(BASE + path, fetchOpts);
|
||||||
|
|
||||||
if (r.status === 401 && !path.includes("/auth/refresh")) {
|
if (r.status === 401 && !path.includes("/auth/refresh")) {
|
||||||
const refreshed = await tryRefresh();
|
const refreshed = await tryRefreshSession();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
r = await fetch(BASE + path, { ...opts, headers });
|
r = await fetch(BASE + path, fetchOpts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
setToken("");
|
setToken("");
|
||||||
setRefreshToken("");
|
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const error = new Error(`HTTP ${r.status}`);
|
const error = new Error(`HTTP ${r.status}`);
|
||||||
error.status = r.status;
|
error.status = r.status;
|
||||||
try { error.body = await r.json(); } catch { }
|
try { error.body = await r.json(); } catch {}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -131,6 +151,10 @@ export function checkAuth() {
|
|||||||
return request("/auth/me");
|
return request("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return request("/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
export function getPreferences() {
|
export function getPreferences() {
|
||||||
return get("/auth/preferences");
|
return get("/auth/preferences");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
|
import { login, setToken } from "../lib/api.js";
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
|
||||||
let username = $state("");
|
let username = $state("");
|
||||||
@@ -56,7 +56,6 @@
|
|||||||
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setToken(data.access_token);
|
setToken(data.access_token);
|
||||||
setRefreshToken(data.refresh_token);
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
|
||||||
@@ -79,7 +78,6 @@
|
|||||||
const res = await login(payload); // api.js helper sends JSON
|
const res = await login(payload); // api.js helper sends JSON
|
||||||
|
|
||||||
setToken(res.access_token);
|
setToken(res.access_token);
|
||||||
setRefreshToken(res.refresh_token);
|
|
||||||
// Determine if we need to reload or just update state.
|
// Determine if we need to reload or just update state.
|
||||||
// Reload is safest to clear any stale state.
|
// Reload is safest to clear any stale state.
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -36,19 +36,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function registerPasskey() {
|
async function registerPasskey() {
|
||||||
|
alert("registerPasskey called!");
|
||||||
passkeyLoading = true;
|
passkeyLoading = true;
|
||||||
passkeyMsg = ""; passkeyErr = "";
|
passkeyMsg = ""; passkeyErr = "";
|
||||||
try {
|
try {
|
||||||
|
alert("Checking PublicKeyCredential support...");
|
||||||
if (!window.PublicKeyCredential) {
|
if (!window.PublicKeyCredential) {
|
||||||
|
alert("PublicKeyCredential NOT supported!");
|
||||||
throw new Error("Passkeys are not supported in this browser or context");
|
throw new Error("Passkeys are not supported in this browser or context");
|
||||||
}
|
}
|
||||||
|
alert("Fetching registration options...");
|
||||||
const opts = await post("/auth/passkey/register/options");
|
const opts = await post("/auth/passkey/register/options");
|
||||||
|
alert("Got options: " + JSON.stringify(opts).substring(0, 100));
|
||||||
opts.challenge = base64urlToBuffer(opts.challenge);
|
opts.challenge = base64urlToBuffer(opts.challenge);
|
||||||
opts.user.id = base64urlToBuffer(opts.user.id);
|
opts.user.id = base64urlToBuffer(opts.user.id);
|
||||||
if (opts.excludeCredentials) {
|
if (opts.excludeCredentials) {
|
||||||
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
|
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
|
||||||
}
|
}
|
||||||
const cred = await navigator.credentials.create({ publicKey: opts });
|
const cred = await navigator.credentials.create({ publicKey: opts });
|
||||||
|
if (!cred) {
|
||||||
|
throw new Error("Passkey creation was cancelled");
|
||||||
|
}
|
||||||
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
|
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
|
||||||
const body = {
|
const body = {
|
||||||
id: cred.id,
|
id: cred.id,
|
||||||
@@ -64,9 +72,13 @@
|
|||||||
passkeyMsg = "Passkey registered successfully";
|
passkeyMsg = "Passkey registered successfully";
|
||||||
await loadPasskeys();
|
await loadPasskeys();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
|
console.error("Passkey registration error:", e);
|
||||||
|
const errorMsg = e.body?.detail || e.message || "Failed to register passkey";
|
||||||
|
passkeyErr = errorMsg;
|
||||||
|
alert("Passkey Error: " + errorMsg + "\n\nFull error: " + JSON.stringify(e, null, 2));
|
||||||
|
} finally {
|
||||||
|
passkeyLoading = false;
|
||||||
}
|
}
|
||||||
passkeyLoading = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearPasskeys() {
|
async function clearPasskeys() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { VoiceSession } from "../lib/voice.js";
|
import { VoiceSession } from "../lib/voice.js";
|
||||||
import { getToken } from "../lib/api.js";
|
import { tryRefreshSession } from "../lib/api.js";
|
||||||
|
|
||||||
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
|
||||||
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
|
||||||
@@ -189,34 +189,70 @@
|
|||||||
fit.fit();
|
fit.fit();
|
||||||
|
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const token = getToken() || "";
|
|
||||||
let heartbeat = null;
|
let heartbeat = null;
|
||||||
|
let pongTimeout = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let closedManually = false;
|
let closedManually = false;
|
||||||
let reconnecting = false;
|
let reconnecting = false;
|
||||||
|
let authRecoveryInFlight = false;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let consecutiveAuthFailures = 0;
|
||||||
|
const MAX_RECONNECT_DELAY = 30000;
|
||||||
|
const MAX_AUTH_FAILURES = 3;
|
||||||
|
const PING_INTERVAL = 30000; // 30s between pings
|
||||||
|
const PONG_TIMEOUT = 10000; // 10s to receive pong
|
||||||
|
|
||||||
function clearHeartbeat() {
|
function clearHeartbeat() {
|
||||||
if (heartbeat) {
|
if (heartbeat) {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
heartbeat = null;
|
heartbeat = null;
|
||||||
}
|
}
|
||||||
|
if (pongTimeout) {
|
||||||
|
clearTimeout(pongTimeout);
|
||||||
|
pongTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthExpired(reason = "Session expired — please log in again") {
|
||||||
|
clearHeartbeat();
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
reconnecting = false;
|
||||||
|
updateTab(tabId, {
|
||||||
|
connected: false,
|
||||||
|
error: reason,
|
||||||
|
statusBanner: "",
|
||||||
|
});
|
||||||
|
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
|
||||||
|
const d = tabData.get(tabId);
|
||||||
|
if (d) {
|
||||||
|
d.ws = null;
|
||||||
|
d.heartbeat = heartbeat;
|
||||||
|
d.reconnectTimer = reconnectTimer;
|
||||||
|
d.closedManually = closedManually;
|
||||||
|
d.reconnecting = reconnecting;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect(reason) {
|
function scheduleReconnect(reason) {
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
if (closedManually || reconnectTimer) return;
|
if (closedManually || reconnectTimer) return;
|
||||||
reconnecting = true;
|
reconnecting = true;
|
||||||
|
reconnectAttempts++;
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
|
||||||
updateTab(tabId, {
|
updateTab(tabId, {
|
||||||
connected: false,
|
connected: false,
|
||||||
error: `${reason} — reconnecting...`,
|
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
|
||||||
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||||
});
|
});
|
||||||
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
|
term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
if (!tabData.has(tabId) || closedManually) return;
|
if (!tabData.has(tabId) || closedManually) return;
|
||||||
connect();
|
connect(true);
|
||||||
}, 2000);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSize(ws) {
|
function sendSize(ws) {
|
||||||
@@ -229,14 +265,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
async function connect(forceRefresh = false) {
|
||||||
const token = getToken() || "";
|
let d = tabData.get(tabId);
|
||||||
|
|
||||||
|
if (forceRefresh) {
|
||||||
|
const refreshed = await tryRefreshSession();
|
||||||
|
if (!refreshed) {
|
||||||
|
handleAuthExpired();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ws = new WebSocket(
|
const ws = new WebSocket(
|
||||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
|
||||||
);
|
);
|
||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
|
||||||
updateTab(tabId, {
|
updateTab(tabId, {
|
||||||
connected: true,
|
connected: true,
|
||||||
error: "",
|
error: "",
|
||||||
@@ -245,8 +292,18 @@
|
|||||||
sendSize(ws);
|
sendSize(ws);
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
if (ws.readyState === 1) ws.send("__ping__");
|
if (ws.readyState === 1) {
|
||||||
}, 25000);
|
ws.send("__ping__");
|
||||||
|
// Set timeout to detect missing pong
|
||||||
|
pongTimeout = setTimeout(() => {
|
||||||
|
// Check if still the same WebSocket instance and still open
|
||||||
|
const currentTab = tabData.get(tabId);
|
||||||
|
if (currentTab?.ws === ws && ws.readyState === 1) {
|
||||||
|
ws.close(1000, "keepalive ping timeout");
|
||||||
|
}
|
||||||
|
}, PONG_TIMEOUT);
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL);
|
||||||
const d = tabData.get(tabId);
|
const d = tabData.get(tabId);
|
||||||
if (d) {
|
if (d) {
|
||||||
d.ws = ws;
|
d.ws = ws;
|
||||||
@@ -258,6 +315,16 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
|
// Check for text pong response
|
||||||
|
if (typeof e.data === 'string' && e.data === '__pong__') {
|
||||||
|
// Clear the pong timeout - connection is alive
|
||||||
|
if (pongTimeout) {
|
||||||
|
clearTimeout(pongTimeout);
|
||||||
|
pongTimeout = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = new Uint8Array(e.data);
|
const data = new Uint8Array(e.data);
|
||||||
const isPersistenceMessage = tab.persistent
|
const isPersistenceMessage = tab.persistent
|
||||||
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
|
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
|
||||||
@@ -272,10 +339,48 @@
|
|||||||
}
|
}
|
||||||
term.write(data);
|
term.write(data);
|
||||||
};
|
};
|
||||||
ws.onclose = (e) => {
|
ws.onclose = async (e) => {
|
||||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
const reason = e.reason || `Connection closed (code ${e.code})`;
|
||||||
const d = tabData.get(tabId);
|
const tabState = tabData.get(tabId);
|
||||||
if (d) d.ws = null;
|
if (tabState) tabState.ws = null;
|
||||||
|
|
||||||
|
// Handle auth failures (code 1008)
|
||||||
|
if (e.code === 1008) {
|
||||||
|
consecutiveAuthFailures++;
|
||||||
|
|
||||||
|
// Stop after MAX_AUTH_FAILURES consecutive auth failures
|
||||||
|
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
|
||||||
|
handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to refresh session
|
||||||
|
if (!closedManually && !authRecoveryInFlight) {
|
||||||
|
authRecoveryInFlight = true;
|
||||||
|
try {
|
||||||
|
const refreshed = await tryRefreshSession();
|
||||||
|
if (refreshed && !closedManually) {
|
||||||
|
reconnecting = true;
|
||||||
|
updateTab(tabId, {
|
||||||
|
connected: false,
|
||||||
|
error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
|
||||||
|
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
|
||||||
|
});
|
||||||
|
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
|
||||||
|
void connect(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Session refresh failed:", err);
|
||||||
|
} finally {
|
||||||
|
authRecoveryInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleAuthExpired("Session expired — please log in again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-auth failures: continue with normal reconnection
|
||||||
if (!closedManually) scheduleReconnect(reason);
|
if (!closedManually) scheduleReconnect(reason);
|
||||||
else {
|
else {
|
||||||
clearHeartbeat();
|
clearHeartbeat();
|
||||||
@@ -284,13 +389,13 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
const d = tabData.get(tabId);
|
const tabState = tabData.get(tabId);
|
||||||
if (d) d.ws = null;
|
if (tabState) tabState.ws = null;
|
||||||
if (!closedManually) scheduleReconnect("Connection failed");
|
if (!closedManually) scheduleReconnect("Connection failed");
|
||||||
else updateTab(tabId, { connected: false, error: "Connection failed" });
|
else updateTab(tabId, { connected: false, error: "Connection failed" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const d = tabData.get(tabId);
|
d = tabData.get(tabId);
|
||||||
if (d) {
|
if (d) {
|
||||||
d.ws = ws;
|
d.ws = ws;
|
||||||
d.heartbeat = heartbeat;
|
d.heartbeat = heartbeat;
|
||||||
@@ -301,7 +406,11 @@
|
|||||||
return ws;
|
return ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ws = connect();
|
let ws = null;
|
||||||
|
void connect().then((connectedWs) => {
|
||||||
|
ws = connectedWs;
|
||||||
|
if (connectedWs) voice.attach(term, connectedWs);
|
||||||
|
});
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
const currentWs = tabData.get(tabId)?.ws;
|
const currentWs = tabData.get(tabId)?.ws;
|
||||||
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
|
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Component tests for Login.svelte
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
|
import Login from '../../src/routes/Login.svelte';
|
||||||
|
|
||||||
|
// Mock the API module
|
||||||
|
vi.mock('../../src/lib/api.js', () => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
setToken: vi.fn(),
|
||||||
|
setCurrentUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Login Component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render login form', () => {
|
||||||
|
render(Login);
|
||||||
|
|
||||||
|
// Check for username and password inputs
|
||||||
|
expect(screen.getByLabelText(/username/i) || screen.getByPlaceholderText(/username/i)).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText(/password/i) || screen.getByPlaceholderText(/password/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have login button', () => {
|
||||||
|
render(Login);
|
||||||
|
|
||||||
|
const loginButton = screen.getByRole('button', { name: /login/i }) ||
|
||||||
|
screen.getByText(/login/i);
|
||||||
|
expect(loginButton).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept user input', async () => {
|
||||||
|
render(Login);
|
||||||
|
|
||||||
|
const usernameInput = screen.getByLabelText(/username/i) ||
|
||||||
|
screen.getByPlaceholderText(/username/i);
|
||||||
|
const passwordInput = screen.getByLabelText(/password/i) ||
|
||||||
|
screen.getByPlaceholderText(/password/i);
|
||||||
|
|
||||||
|
await fireEvent.input(usernameInput, { target: { value: 'testuser' } });
|
||||||
|
await fireEvent.input(passwordInput, { target: { value: 'testpass' } });
|
||||||
|
|
||||||
|
expect(usernameInput.value).toBe('testuser');
|
||||||
|
expect(passwordInput.value).toBe('testpass');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Global test setup for Vitest
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock Web APIs that may not be available in jsdom
|
||||||
|
|
||||||
|
// Mock WebSocket
|
||||||
|
global.WebSocket = class MockWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = 0; // CONNECTING
|
||||||
|
setTimeout(() => {
|
||||||
|
this.readyState = 1; // OPEN
|
||||||
|
this.onopen?.();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
send(data) {}
|
||||||
|
close() {
|
||||||
|
this.readyState = 3; // CLOSED
|
||||||
|
this.onclose?.();
|
||||||
|
}
|
||||||
|
addEventListener(event, handler) {
|
||||||
|
this[`on${event}`] = handler;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Web Speech API
|
||||||
|
global.SpeechRecognition = vi.fn(() => ({
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
abort: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.webkitSpeechRecognition = global.SpeechRecognition;
|
||||||
|
|
||||||
|
global.speechSynthesis = {
|
||||||
|
speak: vi.fn(),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
resume: vi.fn(),
|
||||||
|
getVoices: vi.fn(() => [])
|
||||||
|
};
|
||||||
|
|
||||||
|
global.SpeechSynthesisUtterance = vi.fn();
|
||||||
|
|
||||||
|
// Mock WebAuthn
|
||||||
|
global.navigator.credentials = {
|
||||||
|
create: vi.fn(),
|
||||||
|
get: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
removeItem: vi.fn(),
|
||||||
|
clear: vi.fn()
|
||||||
|
};
|
||||||
|
global.localStorage = localStorageMock;
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
global.ResizeObserver = vi.fn(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock MutationObserver
|
||||||
|
global.MutationObserver = vi.fn(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
takeRecords: vi.fn(() => [])
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
// Reset mocks before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for API client (api.js)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
setToken,
|
||||||
|
getToken,
|
||||||
|
setCurrentUser,
|
||||||
|
currentUser,
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
del,
|
||||||
|
put,
|
||||||
|
login,
|
||||||
|
checkAuth,
|
||||||
|
logout,
|
||||||
|
tryRefreshSession,
|
||||||
|
} from '../src/lib/api.js';
|
||||||
|
|
||||||
|
describe('API Client - Token Management', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
setToken('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get token', () => {
|
||||||
|
setToken('test-token-123');
|
||||||
|
expect(getToken()).toBe('test-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store token in localStorage', () => {
|
||||||
|
setToken('test-token-123');
|
||||||
|
expect(localStorage.setItem).toHaveBeenCalledWith('token', 'test-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove token from localStorage when cleared', () => {
|
||||||
|
setToken('test-token-123');
|
||||||
|
setToken('');
|
||||||
|
expect(localStorage.removeItem).toHaveBeenCalledWith('token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set current user', () => {
|
||||||
|
const user = { username: 'testuser', role: 'admin', pages: ['dashboard'] };
|
||||||
|
setCurrentUser(user);
|
||||||
|
expect(currentUser).toEqual(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - GET Requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make GET request with auth header', async () => {
|
||||||
|
const mockData = { result: 'success' };
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await get('/test/endpoint');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test/endpoint',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make GET request without auth header when no token', async () => {
|
||||||
|
setToken('');
|
||||||
|
const mockData = { result: 'success' };
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
await get('/test/endpoint');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test/endpoint',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: expect.not.objectContaining({
|
||||||
|
Authorization: expect.anything(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on 404', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({ error: 'Not found' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(get('/test/endpoint')).rejects.toThrow('HTTP 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - POST Requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make POST request with JSON body', async () => {
|
||||||
|
const mockData = { result: 'created' };
|
||||||
|
const postData = { name: 'test', value: 123 };
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await post('/test/endpoint', postData);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test/endpoint',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(postData),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - DELETE Requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make DELETE request', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ message: 'deleted' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await del('/test/endpoint');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test/endpoint',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - PUT Requests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make PUT request with JSON body', async () => {
|
||||||
|
const putData = { name: 'updated' };
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ result: 'updated' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await put('/test/endpoint', putData);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test/endpoint',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(putData),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - Authentication Methods', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login with credentials', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
access_token: 'new-token',
|
||||||
|
user: 'testuser',
|
||||||
|
role: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await login({ username: 'testuser', password: 'pass123' });
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/login',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check auth status', async () => {
|
||||||
|
setToken('test-token');
|
||||||
|
const mockUser = { username: 'testuser', role: 'admin' };
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await checkAuth();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/auth/me', expect.anything());
|
||||||
|
expect(result).toEqual(mockUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout', async () => {
|
||||||
|
setToken('test-token');
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ message: 'logged out' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await logout();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/auth/logout',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - Token Refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh token from cookie', async () => {
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'new-token' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tryRefreshSession();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getToken()).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should try legacy refresh token if cookie refresh fails', async () => {
|
||||||
|
localStorage.getItem.mockReturnValue('legacy-refresh-token');
|
||||||
|
|
||||||
|
// First call (cookie) fails
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second call (legacy) succeeds
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'new-token-from-legacy' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tryRefreshSession();
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getToken()).toBe('new-token-from-legacy');
|
||||||
|
expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if all refresh attempts fail', async () => {
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tryRefreshSession();
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-refresh on 401 response', async () => {
|
||||||
|
setToken('expired-token');
|
||||||
|
|
||||||
|
// First request returns 401
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh succeeds
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ access_token: 'refreshed-token' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry original request succeeds
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: 'success' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await get('/test/endpoint');
|
||||||
|
|
||||||
|
expect(result).toEqual({ data: 'success' });
|
||||||
|
expect(getToken()).toBe('refreshed-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear token on final 401', async () => {
|
||||||
|
setToken('expired-token');
|
||||||
|
|
||||||
|
// First request returns 401
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh fails
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retry original request still 401
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(get('/test/endpoint')).rejects.toThrow('Unauthorized');
|
||||||
|
expect(getToken()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API Client - Error Handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setToken('test-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network errors', async () => {
|
||||||
|
global.fetch.mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
|
await expect(get('/test/endpoint')).rejects.toThrow('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include error body in thrown error', async () => {
|
||||||
|
const errorBody = { error: 'Bad request', details: 'Invalid input' };
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: async () => errorBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await get('/test/endpoint');
|
||||||
|
expect.fail('Should have thrown');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.status).toBe(400);
|
||||||
|
expect(error.body).toEqual(errorBody);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte({ hot: !process.env.VITEST })],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./tests/setup.js'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'html', 'lcov'],
|
||||||
|
exclude: ['node_modules/', 'tests/', 'dist/', '*.config.js']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:2283:2283"
|
- "0.0.0.0:2283:2283"
|
||||||
volumes:
|
volumes:
|
||||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||||
- /volume1/photo:/mnt/photo:ro
|
- /volume1/photo:/mnt/photo:ro
|
||||||
@@ -18,9 +18,10 @@ services:
|
|||||||
DB_PASSWORD: ${DB_PASSWORD}
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
DB_DATABASE_NAME: ${DB_DATABASE_NAME}
|
DB_DATABASE_NAME: ${DB_DATABASE_NAME}
|
||||||
REDIS_HOSTNAME: immich-redis
|
REDIS_HOSTNAME: immich-redis
|
||||||
MACHINE_LEARNING_URL: http://100.65.185.12:3003
|
IMMICH_MACHINE_LEARNING_ENABLED: "false"
|
||||||
|
IMMICH_MACHINE_LEARNING_URL: ""
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:2283/api/server/ping"]
|
test: ["CMD-SHELL", "curl -f http://localhost:2283/api/server/ping || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# NAS Platform Comparison
|
||||||
|
|
||||||
|
| Feature | TrueNAS | Unraid | Synology (benchmark) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Cost | Free (CORE/SCALE) | $59–$129 license | Hardware + DSM included |
|
||||||
|
| OS Base | FreeBSD (CORE) / Linux (SCALE) | Linux (Slackware) | Linux (DSM) |
|
||||||
|
| File System | ZFS | XFS/Btrfs per disk, custom parity | Btrfs / ext4 |
|
||||||
|
| RAID Approach | ZFS pools (raidz1/2/3, mirror) | Custom parity (mixed disk sizes OK) | Standard RAID + SHR |
|
||||||
|
| Drive Flexibility | All drives in vdev must match | Mix any sizes, add one at a time | Must match within RAID group |
|
||||||
|
| Data Protection | ZFS checksums, scrubs, snapshots | Single/dual parity, no checksums on data | Btrfs checksums (if Btrfs), snapshots |
|
||||||
|
| VM Support | Yes (bhyve/KVM) | Yes (KVM, well-integrated) | Yes (Virtual Machine Manager) |
|
||||||
|
| Docker/Containers | Yes (SCALE has native K3s/Docker) | Yes (excellent Docker support) | Yes (Container Manager) |
|
||||||
|
| App Ecosystem | TrueCharts / built-in apps | Community Apps (large catalog) | Synology Package Center (curated) |
|
||||||
|
| Hardware | DIY / any x86 | DIY / any x86 | Proprietary appliance |
|
||||||
|
| Ease of Use | Moderate (more admin knowledge) | Easy (great web UI) | Easiest (polished consumer UX) |
|
||||||
|
| Performance | High (ZFS ARC caching, ECC RAM rec.) | Good (parity rebuild is slower) | Good for its hardware class |
|
||||||
|
| Best For | Data integrity, enterprise/homelab | Media server, flexible home storage | Set-and-forget, non-technical users |
|
||||||
@@ -28,6 +28,12 @@ nas.jimmygan.com {
|
|||||||
reverse_proxy 100.78.131.124:4000
|
reverse_proxy 100.78.131.124:4000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dev.nas.jimmygan.com {
|
||||||
|
import security_headers
|
||||||
|
import authelia
|
||||||
|
reverse_proxy 100.78.131.124:4001
|
||||||
|
}
|
||||||
|
|
||||||
music.jimmygan.com {
|
music.jimmygan.com {
|
||||||
import security_headers
|
import security_headers
|
||||||
import authelia
|
import authelia
|
||||||
|
|||||||
Reference in New Issue
Block a user