# 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