feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s

This commit is contained in:
Patrick Plate
2026-06-18 16:08:05 +02:00
parent 279487067e
commit f9a87efb7a
17 changed files with 1962 additions and 107 deletions
@@ -0,0 +1,585 @@
# Testplan: Sprint 13 — Production Hardening
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v1
**Basis:** cannamanage-sprint13-plan.md
---
## Test Overview
| ID | Description | Type | Class | Status |
|----|-------------|------|-------|--------|
| T-01 | Path traversal filename sanitization | Unit | `DocumentServiceTest` | ⬜ |
| T-02 | Null filename fallback | Unit | `DocumentServiceTest` | ⬜ |
| T-03 | Valid filename preserved | Unit | `DocumentServiceTest` | ⬜ |
| T-04 | Download wrong tenant — forbidden | Unit | `DocumentServiceTest` | ⬜ |
| T-05 | Download correct tenant — success | Unit | `DocumentServiceTest` | ⬜ |
| T-06 | Delete wrong tenant — forbidden | Unit | `DocumentServiceTest` | ⬜ |
| T-07 | Delete admin role — success | Unit | `DocumentServiceTest` | ⬜ |
| T-08 | Download unauthenticated — 401 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-09 | Download wrong tenant — 403 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-10 | Download correct tenant — 200 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-11 | Delete as MEMBER — 403 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-12 | Delete as STAFF — 200 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-13 | Upload as MEMBER — 403 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-14 | Upload as STAFF — 200 | Integration | `DocumentControllerSecurityTest` | ⬜ |
| T-15 | Login valid credentials — token pair | Unit | `AuthServiceTest` | ⬜ |
| T-16 | Login invalid password — 401 | Unit | `AuthServiceTest` | ⬜ |
| T-17 | Login non-existent user — 401 | Unit | `AuthServiceTest` | ⬜ |
| T-18 | Refresh token valid — new access token | Unit | `AuthServiceTest` | ⬜ |
| T-19 | Refresh token expired — 401 | Unit | `AuthServiceTest` | ⬜ |
| T-20 | SHA-256 hashing deterministic | Unit | `AuthServiceTest` | ⬜ |
| T-21 | Document endpoints require auth | Integration | `SecurityConfigTest` | ⬜ |
| T-22 | Auth endpoints are public | Integration | `SecurityConfigTest` | ⬜ |
| T-23 | Actuator health is public | Integration | `SecurityConfigTest` | ⬜ |
| T-24 | CORS allows configured origin | Integration | `SecurityConfigTest` | ⬜ |
| T-25 | CORS rejects unconfigured origin | Integration | `SecurityConfigTest` | ⬜ |
| T-26 | Rate limit — 5 requests pass | Integration | `LoginRateLimitFilterTest` | ⬜ |
| T-27 | Rate limit — 6th request returns 429 | Integration | `LoginRateLimitFilterTest` | ⬜ |
| T-28 | Rate limit — different IPs independent | Integration | `LoginRateLimitFilterTest` | ⬜ |
| T-29 | Rate limiter evicts stale entries (Caffeine TTL) | Unit | `LoginRateLimitFilterTest` | ⬜ |
| T-30 | CI backend tests run on push | Manual | CI/CD verification | ⬜ |
| T-31 | CI frontend lint runs on push | Manual | CI/CD verification | ⬜ |
| T-32 | CI blocks deploy on test failure | Manual | CI/CD verification | ⬜ |
Status: ⬜ Open | ✅ Passed | ❌ Failed | ⏭️ Skipped
---
## Test Cases
### T-01: Path Traversal Filename Sanitization
**Type:** Unit
**Class:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java`
**Method:** `testUploadDocument_pathTraversalFilename_isSanitized()`
**Preconditions:**
- DocumentService instantiated with mocked dependencies
- Mock file storage path configured
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `"../../etc/passwd.pdf"` | Stored as `passwd.pdf` (path components stripped) |
| b | `"../../../tmp/evil.txt"` | Stored as `evil.txt` |
| c | `"..\\..\\windows\\system32\\bad.exe"` | Stored as `bad.exe` (backslash traversal) |
| d | `"normal-document.pdf"` | Stored as `normal-document.pdf` (unchanged) |
**Postconditions:**
- File is stored under `{UPLOAD_BASE}/{clubId}/{docId}_{sanitizedFilename}`
- No path escapes the upload base directory
---
### T-02: Null Filename Fallback
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testUploadDocument_nullFilename_usesFallback()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `null` filename | Stored as `"document"` |
| b | Empty string `""` | Stored as `"document"` |
| c | Whitespace only `" "` | Stored as `"document"` |
---
### T-03: Valid Filename Preserved
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testUploadDocument_validFilename_preserved()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `"meeting-notes-2026.pdf"` | Stored as-is |
| b | `"Mitgliederversammlung Protokoll.docx"` | Stored as-is (spaces allowed) |
| c | `"report_v2.1_final.xlsx"` | Stored as-is (dots and underscores) |
---
### T-04: Download Wrong Tenant — Forbidden
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testDownloadDocument_wrongTenant_throwsForbidden()`
**Preconditions:**
- Document exists with `clubId = "club-A"`
- Current user's tenant context = `"club-B"`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Request document by UUID belonging to different club | `AccessDeniedException` thrown |
**Postconditions:**
- No file content is returned
- Audit log records the denied access attempt
---
### T-05: Download Correct Tenant — Success
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testDownloadDocument_correctTenant_returnsContent()`
**Preconditions:**
- Document exists with `clubId = "club-A"`
- Current user's tenant context = `"club-A"`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Request document by valid UUID, same tenant | File bytes returned successfully |
---
### T-06: Delete Wrong Tenant — Forbidden
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testDeleteDocument_wrongTenant_throwsForbidden()`
**Preconditions:**
- Document exists with `clubId = "club-A"`
- Current user's tenant context = `"club-B"`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Delete request for document in different club | `AccessDeniedException` thrown |
**Postconditions:**
- Document is NOT deleted
- File remains on disk
---
### T-07: Delete Admin Role — Success
**Type:** Unit
**Class:** `DocumentServiceTest`
**Method:** `testDeleteDocument_adminRole_succeeds()`
**Preconditions:**
- Document exists with `clubId = "club-A"`
- Current user: role `ADMIN`, tenant `"club-A"`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Admin deletes own tenant's document | Document removed from DB + file system |
---
### T-08: Download Unauthenticated — 401
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DocumentControllerSecurityTest.java`
**Method:** `testDownload_unauthenticated_returns401()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | GET `/api/v1/documents/{id}/download` with no auth header | HTTP 401 |
---
### T-09: Download Wrong Tenant — 403
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testDownload_wrongTenant_returns403()`
**Preconditions:**
- `@WithMockUser` configured for club-B
- Document belongs to club-A
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Authenticated user requests document from different tenant | HTTP 403 |
---
### T-10: Download Correct Tenant — 200
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testDownload_correctTenant_returns200()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Authenticated user downloads own tenant's document | HTTP 200 + file content |
---
### T-11: Delete as MEMBER — 403
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testDelete_memberRole_returns403()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `@WithMockUser(roles="MEMBER")` → DELETE `/api/v1/documents/{id}` | HTTP 403 |
---
### T-12: Delete as STAFF — 200
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testDelete_staffRole_returns200()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `@WithMockUser(roles="STAFF")` → DELETE `/api/v1/documents/{id}` | HTTP 200 or 204 |
---
### T-13: Upload as MEMBER — 403
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testUpload_memberRole_returns403()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `@WithMockUser(roles="MEMBER")` → POST `/api/v1/documents` | HTTP 403 |
---
### T-14: Upload as STAFF — 200
**Type:** Integration
**Class:** `DocumentControllerSecurityTest`
**Method:** `testUpload_staffRole_returns200()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `@WithMockUser(roles="STAFF")` → POST `/api/v1/documents` with multipart file | HTTP 200 or 201 |
---
### T-15: Login Valid Credentials — Token Pair
**Type:** Unit
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/service/AuthServiceTest.java`
**Method:** `testLogin_validCredentials_returnsTokenPair()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid email + matching BCrypt password | Response contains `accessToken` (non-null, non-empty) + `refreshToken` |
---
### T-16: Login Invalid Password — 401
**Type:** Unit
**Class:** `AuthServiceTest`
**Method:** `testLogin_invalidPassword_throws401()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid email + wrong password | Exception with 401 semantics |
| b | Valid email + empty password | Exception with 401 semantics |
---
### T-17: Login Non-Existent User — 401
**Type:** Unit
**Class:** `AuthServiceTest`
**Method:** `testLogin_nonExistentUser_throws401()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `unknown@example.com` + any password | Exception with 401 semantics |
**Postconditions:**
- Timing is consistent with valid-user path (prevent user enumeration)
---
### T-18: Refresh Token Valid — New Access Token
**Type:** Unit
**Class:** `AuthServiceTest`
**Method:** `testRefreshToken_validToken_returnsNewAccess()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Valid, non-expired refresh token | New access token returned, refresh token rotated |
---
### T-19: Refresh Token Expired — 401
**Type:** Unit
**Class:** `AuthServiceTest`
**Method:** `testRefreshToken_expired_throws401()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Refresh token past expiry date | Exception with 401 semantics |
| b | Token hash doesn't match stored hash | Exception with 401 semantics |
---
### T-20: SHA-256 Hashing Deterministic
**Type:** Unit
**Class:** `AuthServiceTest`
**Method:** `testSha256_sameInput_sameOutput()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `"test-token-123"` hashed twice | Both results are identical |
| b | `"different-input"` | Different hash than input (a) |
---
### T-21: Document Endpoints Require Auth
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/security/SecurityConfigTest.java`
**Method:** `testDocumentEndpoints_requireAuthentication()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | GET `/api/v1/documents` without auth | HTTP 401 |
| b | POST `/api/v1/documents` without auth | HTTP 401 |
| c | DELETE `/api/v1/documents/{id}` without auth | HTTP 401 |
---
### T-22: Auth Endpoints Are Public
**Type:** Integration
**Class:** `SecurityConfigTest`
**Method:** `testAuthEndpoints_arePublic()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | POST `/api/v1/auth/login` without auth | HTTP 200 or 400 (not 401) |
| b | POST `/api/v1/auth/register` without auth | HTTP 200 or 400 (not 401) |
---
### T-23: Actuator Health Is Public
**Type:** Integration
**Class:** `SecurityConfigTest`
**Method:** `testActuatorHealth_isPublic()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | GET `/actuator/health` without auth | HTTP 200 |
---
### T-24: CORS Allows Configured Origin
**Type:** Integration
**Class:** `SecurityConfigTest`
**Method:** `testCors_allowedOrigin_returns200()`
**Preconditions:**
- `cannamanage.cors.allowed-origins=http://localhost:3000,https://app.cannamanage.de`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `Origin: http://localhost:3000` | `Access-Control-Allow-Origin: http://localhost:3000` |
| b | `Origin: https://app.cannamanage.de` | `Access-Control-Allow-Origin: https://app.cannamanage.de` |
---
### T-25: CORS Rejects Unconfigured Origin
**Type:** Integration
**Class:** `SecurityConfigTest`
**Method:** `testCors_unconfiguredOrigin_noHeader()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | `Origin: https://evil.com` | No `Access-Control-Allow-Origin` header in response |
---
### T-26: Rate Limit — 5 Requests Pass
**Type:** Integration
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimitFilterTest.java`
**Method:** `testRateLimit_5requests_allPass()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | 5 POST requests to `/api/v1/auth/login` from same IP within 1 minute | All return normal response (200 or 401) |
---
### T-27: Rate Limit — 6th Request Returns 429
**Type:** Integration
**Class:** `LoginRateLimitFilterTest`
**Method:** `testRateLimit_6thRequest_returns429()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | 6th POST to `/api/v1/auth/login` from same IP within 1 minute | HTTP 429 + `Retry-After` header |
---
### T-28: Rate Limit — Different IPs Independent
**Type:** Integration
**Class:** `LoginRateLimitFilterTest`
**Method:** `testRateLimit_differentIPs_independent()`
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | 5 requests from IP-A, then 1 request from IP-B | IP-B request passes normally |
---
### T-29: Rate Limiter Evicts Stale Entries (Caffeine TTL)
**Type:** Unit
**Class:** `cannamanage-api/src/test/java/de/cannamanage/api/security/LoginRateLimitFilterTest.java`
**Method:** `testRateLimiter_evictsStaleEntries()`
**Preconditions:**
- Caffeine cache configured with short TTL for testing (override via `@TestPropertySource` or direct instantiation)
**Scenarios:**
| # | Input | Expected Result |
|---|-------|-----------------|
| a | Exhaust 5 attempts from IP-A, wait for TTL to expire, then attempt again | Request passes (bucket evicted and recreated) |
| b | Verify cache size does not grow unbounded after many unique IPs | Cache respects `maximumSize(10_000)` — old entries evicted |
**Postconditions:**
- No memory leak under simulated DDoS (many unique IPs)
- Stale rate limit buckets are automatically cleaned up
---
### T-30: CI Backend Tests Run on Push
**Type:** Manual
**Verification:** Push a commit to `main`, verify Gitea Actions log shows Maven test step executing.
---
### T-31: CI Frontend Lint Runs on Push
**Type:** Manual
**Verification:** Push a commit to `main`, verify Gitea Actions log shows pnpm lint + type-check step executing.
---
### T-32: CI Blocks Deploy on Test Failure
**Type:** Manual
**Verification:** Introduce a deliberately failing test, push to `main`, verify deployment does NOT proceed and the workflow fails at the test step.
---
## Test Data
### Documents Test Fixtures
- Club A: UUID `"11111111-1111-1111-1111-111111111111"`, document with known UUID
- Club B: UUID `"22222222-2222-2222-2222-222222222222"`, separate document
### Auth Test Fixtures
- Valid user: `"test@example.com"`, BCrypt password hash of `"TestPass123!"`
- Non-existent user: `"ghost@example.com"`
### Rate Limit Test Setup
- Use `MockHttpServletRequest` with different `remoteAddr` values to simulate multiple IPs
---
## Test Coverage
| Component | Unit | Integration | Manual | Total |
|-----------|------|-------------|--------|-------|
| DocumentService | 7 | 0 | 0 | 7 |
| DocumentController | 0 | 7 | 0 | 7 |
| AuthService | 6 | 0 | 0 | 6 |
| SecurityConfig | 0 | 5 | 0 | 5 |
| LoginRateLimitFilter | 1 | 3 | 0 | 4 |
| CI/CD Pipeline | 0 | 0 | 3 | 3 |
| **Total** | **14** | **15** | **3** | **32** |
---
## Execution Order
1. Run unit tests first (T-01 through T-07, T-15 through T-20, T-29) — fast, no Spring context
2. Run integration tests (T-08 through T-14, T-21 through T-28) — require `@WebMvcTest` / `@SpringBootTest`
3. Verify CI/CD manually (T-30 through T-32) — after all code is merged
---
## Pass Criteria
- **All 29 automated tests (T-01 through T-29) must pass** before merging
- **All 3 manual tests (T-30 through T-32) must pass** after CI changes are deployed
- **Zero tolerance** on security tests (T-01 through T-14) — any failure is a blocker