36 Commits

Author SHA1 Message Date
Gan, Jimmy 8431920d26 feat: add comprehensive unit and integration testing infrastructure
Run Tests / Backend Tests (pull_request) Has been cancelled
Run Tests / Frontend Tests (pull_request) Has been cancelled
Run Tests / Test Summary (pull_request) Has been cancelled
- Backend: pytest with unit tests (auth, rbac, config) and integration tests (auth flow, docker, files)
- Frontend: vitest with unit tests (api client) and component tests (login)
- CI: Gitea Actions workflow for automated testing with coverage reports
- Documentation: TESTING.md guide with setup, usage, and best practices
- Coverage goals: 80%+ backend, 70%+ frontend
2026-03-30 11:11:39 +08:00
Gan, Jimmy 99b626eadc fix: add 60s timeout to registry mirror pre-pull to prevent CI hang 2026-03-20 01:04:14 +08:00
Gan, Jimmy 0c45efc0ca fix: terminal auth — skip pre-emptive refresh, always refresh on app load for fresh cookies 2026-03-20 01:04:14 +08:00
Gan, Jimmy 59da4eb9f9 fix: explicitly disable ML URL to prevent OCR job failures 2026-03-20 01:04:14 +08:00
Gan, Jimmy 24cb173caf fix: remove ffmpeg thread limit causing SIGSEGV in Immich 2026-03-20 01:04:14 +08:00
Gan, Jimmy 4eae0dca2f fix: correct tea download URL in Dockerfile 2026-03-20 01:04:14 +08:00
jimmy 69a198f723 Harden terminal WebSocket edge cases
Deploy Dashboard / deploy (push) Successful in 1m1s
Merge pull request #37: Harden terminal WebSocket edge cases
2026-03-15 23:22:08 +08:00
Gan, Jimmy b742ab8fef fix: harden terminal WebSocket edge cases
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m44s
- Wrap tryRefreshSession in try-finally to ensure authRecoveryInFlight is always reset
- Fix variable scope issue: use const tabState instead of reassigning d
- Add error handling for process.stdin.write() to catch BrokenPipeError/OSError
- Verify WebSocket instance before closing in ping timeout callback
- Properly await conn.wait_closed() to prevent SSH connection leaks
- Gracefully cancel and wait for read_task with 2s timeout

These fixes prevent race conditions, resource leaks, and improve error recovery.
2026-03-15 23:18:20 +08:00
Gan, Jimmy d19707422d feat: add tea (Gitea CLI) to claude-dev tooling
Deploy Claude Dev (Dev) / deploy-claude-dev-dev (push) Failing after 40s
2026-03-15 23:00:57 +08:00
jimmy 427decc83a Merge dev to main
Deploy Dashboard / deploy (push) Successful in 1m49s
Merge pull request #36: Immich and Terminal fixes
2026-03-15 22:50:18 +08:00
Gan, Jimmy 9929567679 fix: bind Immich to 0.0.0.0 to allow Tailscale access 2026-03-15 00:29:45 +08:00
Gan, Jimmy d0078f7e9d fix: update Immich healthcheck to use curl and limit ffmpeg threads 2026-03-15 00:22:05 +08:00
Gan, Jimmy d69b744ae7 fix: resolve variable redeclaration in Terminal.svelte
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m50s
Change 'const d' to 'let d' to allow reassignment in nested scopes
2026-03-13 22:45:55 +08:00
Gan, Jimmy 7b17a53cab fix: resolve terminal WebSocket auth failures and improve reconnection
Deploy Dashboard (Dev) / deploy-dev (push) Failing after 1m8s
- Remove insecure query token parameter from backend (cookie-only auth)
- Add detailed JWT error logging (expired, invalid, missing tokens)
- Force token refresh before first WebSocket connection
- Stop reconnection after 3 consecutive auth failures
- Increase ping interval from 12s to 30s (reduce traffic)
- Increase pong timeout from 5s to 10s (handle network latency)
- Show attempt counter in reconnection error messages

Root cause: Frontend was reconnecting with 6-day-old expired token,
causing 403 rejections. Now ensures fresh cookies before connecting.
2026-03-13 22:26:22 +08:00
Gan, Jimmy 00b5d362e7 debug: add detailed logging for terminal WebSocket connections
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m35s
2026-03-12 00:23:23 +08:00
jimmy 0d251a09be fix: add pong/timeout detection to prevent silent connection hangs
Deploy Dashboard / deploy (push) Successful in 49s
Merge pull request #35: Fix pong/timeout detection
2026-03-11 23:58:36 +08:00
Gan, Jimmy e0f432bc39 fix: add pong/timeout detection to prevent silent connection hangs
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m2s
- Backend now responds to __ping__ with __pong__
- Frontend detects missing pong within 5s and closes connection
- This triggers proper reconnect/auth recovery instead of hanging
- Fixes 'keepalive ping timeout' issue where connection appears alive but is dead
2026-03-11 23:55:51 +08:00
jimmy 51ad3bfe5b fix: improve terminal robustness with exponential backoff and better keepalive
Deploy Dashboard / deploy (push) Successful in 1m43s
Merge pull request #34: Terminal robustness improvements
2026-03-11 23:34:36 +08:00
Gan, Jimmy 74100c8882 fix: change WebAuthn RP_ID to jimmygan.com to support all subdomains
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m0s
2026-03-11 01:43:19 +08:00
Gan, Jimmy 5c5baba3d4 debug: add step-by-step alerts to trace passkey registration flow
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m24s
2026-03-11 01:30:12 +08:00
Gan, Jimmy 7277560856 debug: add alert to show WebAuthn errors on iPad
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m11s
- Add alert() to display full error details for debugging
- Helps diagnose issues when browser console is unavailable
2026-03-11 01:25:36 +08:00
Gan, Jimmy b98333db36 fix: correct WebAuthn RP_ID to match domain suffix
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m48s
- Change RP_ID from jimmygan.com to nas.jimmygan.com
- RP_ID must be a valid suffix of the origin domain
- Fixes WebAuthn failing silently on iOS/iPadOS
2026-03-11 01:15:36 +08:00
Gan, Jimmy 42c5693ede fix: add WebAuthn environment variables to docker-compose
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 4m23s
- Set WEBAUTHN_RP_ID=jimmygan.com for both main and dev
- Set WEBAUTHN_ORIGINS to match respective domains
- Fixes passkey registration failing due to origin mismatch
2026-03-11 01:01:14 +08:00
Gan, Jimmy 2f8a5d84dc fix: WebAuthn config typo and improve error handling
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m41s
- Fix WEBAUTHN_ORIGIN -> WEBAUTHN_ORIGINS env var name
- Add null check for cancelled passkey creation
- Use finally block to ensure loading state is always reset
- Add console.error for better debugging
2026-03-11 00:53:28 +08:00
Gan, Jimmy 75cfafd388 chore: remove CI test comment
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m18s
2026-03-11 00:47:22 +08:00
Gan, Jimmy 80ad5e9197 test: verify CI auto-trigger for dev branch
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 5m54s
2026-03-11 00:41:07 +08:00
Gan, Jimmy cc3e9dc6fa fix: improve terminal robustness with exponential backoff and better keepalive
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m11s
- Backend: Add 10s connection timeout, improve SSH keepalive (15s × 8 retries)
- Backend: Add detailed error logging for debugging
- Frontend: Implement exponential backoff (1s → 30s max) instead of fixed 2s
- Frontend: Reduce heartbeat to 12s (within 15s SSH keepalive window)
- Frontend: Reset reconnect counter on successful connection
- Frontend: Fix race condition in auth recovery after manual close
2026-03-11 00:24:17 +08:00
jimmy 0d50958134 Merge pull request 'fix: harden dashboard auth and RBAC security flows' (#33) from dev into main
Deploy Dashboard / deploy (push) Successful in 46s
Merge pull request #33 from dev
2026-03-09 23:53:51 +08:00
Gan, Jimmy 4201917e17 fix: harden dashboard RBAC and cookie auth flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 23:44:23 +08:00
jimmy 1e5b32060e Merge pull request 'fix: refresh terminal websocket auth on reconnect' (#32) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m13s
2026-03-07 17:57:47 +08:00
Gan, Jimmy e72665c466 fix: refresh terminal websocket auth on reconnect
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m16s
Reuse the existing refresh-token flow before opening terminal websocket connections so reconnects can recover from expired access tokens without requiring a full dashboard reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 17:49:46 +08:00
jimmy b580995966 Merge pull request 'Merge dev into main' (#30) from dev into main 2026-03-07 13:09:46 +08:00
Gan, Jimmy c162164528 fix: add dev dashboard caddy route
Add a dedicated dev.nas.jimmygan.com reverse proxy entry so the dev dashboard can be reached through the VPS Caddy configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:50:56 +08:00
jimmy 73e4feb2c3 Merge pull request 'fix: clarify blocked security events' (#29) from dev into main
Deploy Dashboard / deploy (push) Successful in 48s
2026-03-07 11:48:36 +08:00
Gan, Jimmy 175ebeede4 fix: sync dashboard page query state
Deploy Claude Dev (Dev) / deploy-claude-dev-dev (push) Successful in 17m2s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m48s
Keep the selected dashboard page in sync with lightweight URL query params so direct terminal links can land on the right page without exposing extra client state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:02:53 +08:00
Gan, Jimmy c8255dccec fix: add tmux to claude-dev tooling
Add tmux to the claude-dev runtime contract so persistent terminal sessions can rely on it, and make the Claude preflight patch resilient to current CLI builds so image builds keep passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:00:58 +08:00
41 changed files with 3793 additions and 153 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+115
View File
@@ -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
View File
@@ -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)
+503
View File
@@ -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
View File
@@ -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")
+2
View File
@@ -13,4 +13,6 @@ rsync
zip zip
unzip unzip
make make
tmux
tea
claude claude
+47 -21
View File
@@ -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"
+2 -2
View File
@@ -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(",")
+4 -4
View File
@@ -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")
+4
View File
@@ -0,0 +1,4 @@
pytest==8.3.4
pytest-asyncio==0.24.0
pytest-cov==6.0.0
pytest-mock==3.14.0
+78 -31
View File
@@ -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
+3 -2
View File
@@ -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"}
+4 -3
View File
@@ -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)
+3 -2
View File
@@ -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,
+54 -12
View File
@@ -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(
asyncssh.connect(
h["host"], username=h["user"], h["host"], username=h["user"],
client_keys=[h["key"]], known_hosts=_known_hosts, client_keys=[h["key"]], known_hosts=_known_hosts,
keepalive_interval=30, keepalive_interval=15,
keepalive_count_max=3, 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:
try:
process.stdin.write(data) 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
try:
process.stdin.write(msg["text"].encode()) process.stdin.write(msg["text"].encode())
except Exception: except (BrokenPipeError, OSError) as e:
pass 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)
+1
View File
@@ -0,0 +1 @@
# Backend tests
+192
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# Unit tests
+322
View File
@@ -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
+297
View File
@@ -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)
+316
View File
@@ -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"
+2
View File
@@ -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:
+2
View File
@@ -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:
+8 -1
View File
@@ -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"
}, },
+55 -8
View File
@@ -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,9 +121,9 @@
// 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;
@@ -96,6 +132,8 @@
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>
+47 -23
View File
@@ -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() {
if (refreshPromise) return refreshPromise; return localStorage.getItem("refresh_token") || "";
const rt = getRefreshToken(); }
if (!rt) return false;
refreshPromise = (async () => { function clearLegacyRefreshToken() {
try { localStorage.removeItem("refresh_token");
}
async function requestRefresh(body) {
const r = await fetch(BASE + "/auth/refresh", { const r = await fetch(BASE + "/auth/refresh", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: rt }), credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
}); });
if (!r.ok) return false; if (!r.ok) return false;
const data = await r.json(); const data = await r.json();
setToken(data.access_token); setToken(data.access_token || "");
setRefreshToken(data.refresh_token); return !!data.access_token;
}
export async function tryRefreshSession() {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const cookieRefreshed = await requestRefresh();
if (cookieRefreshed) {
clearLegacyRefreshToken();
return true; return true;
}
const legacyRefreshToken = getLegacyRefreshToken();
if (!legacyRefreshToken) return false;
const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
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 -3
View File
@@ -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();
+14 -2
View File
@@ -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,10 +72,14 @@
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() {
if (!confirm("Remove all passkeys?")) return; if (!confirm("Remove all passkeys?")) return;
+127 -18
View File
@@ -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');
});
});
+85
View File
@@ -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);
});
+397
View File
@@ -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);
}
});
});
+21
View File
@@ -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'
}
}
});
+4 -3
View File
@@ -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
+17
View File
@@ -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 |
+6
View File
@@ -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