17 KiB
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:
@WithMockUserconfigured 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
@TestPropertySourceor 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
MockHttpServletRequestwith differentremoteAddrvalues 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
- Run unit tests first (T-01 through T-07, T-15 through T-20, T-29) — fast, no Spring context
- Run integration tests (T-08 through T-14, T-21 through T-28) — require
@WebMvcTest/@SpringBootTest - 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