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,139 @@
# Analysis: Sprint 13 — Production Hardening
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v1
**Sprint Theme:** Production Hardening & Housekeeping
---
## 1. Problem Analysis
CannaManage has completed 12 sprints of feature development and is functionally complete for MVP. However, a comprehensive security review (2026-06-15) identified **4 production-blocking vulnerabilities** that prevent deployment. Additionally, backend test coverage sits at ~12% (20 tests for 29K LOC), the CI/CD pipeline deploys without running tests, and various repo hygiene issues remain unaddressed.
Sprint 13 is a **hardening sprint** — no new features, purely focused on making the existing codebase production-ready.
### Source Documents
- [`docs/security-code-review-final.md`](docs/security-code-review-final.md) — Full security review with 4 BLOCKERs
- [`docs/sprint-12/SPRINT-12-SUMMARY.md`](docs/sprint-12/SPRINT-12-SUMMARY.md) — Sprint 12 outcome (test infra delivered)
- [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml) — Current CI/CD pipeline (no tests)
---
## 2. Affected Components
| Component | Path | Issue |
|-----------|------|-------|
| DocumentController | `cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java` | IDOR — no tenant check on download/delete |
| DocumentService | `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java` | Path traversal via unsanitized filename |
| SecurityConfig | `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java` | Missing `/api/v1/documents/**` matchers, CORS hardcoded |
| deploy.yml | `.gitea/workflows/deploy.yml` | Deploys without running tests |
| package.json | `cannamanage-frontend/package.json` | Wrong project name ("shadboard-nextjs-starter-kit") |
| cannamanage-domain | `cannamanage-domain/src/` | 0 unit tests |
| cannamanage-service | `cannamanage-service/src/` | Low test coverage on service layer |
---
## 3. Current State (Ist-Zustand)
### Security Posture
The security review gave a **CONDITIONAL PASS** — architecture is solid (multi-tenant via `AbstractTenantEntity`, BCrypt+SHA-256, RFC 9457 errors, GoBD append-only audit) but 4 specific issues block go-live:
| # | Blocker | Severity | Status Since |
|---|---------|----------|-------------|
| 1 | IDOR in DocumentController (download by raw UUID, no tenant verify) | HIGH | Sprint 9 (unfixed) |
| 2 | Path traversal in DocumentService (`file.getOriginalFilename()` unsanitized) | HIGH | Sprint 9 (unfixed) |
| 3 | JWT dev-secret fallback | HIGH | **FIXED**`application.properties` now uses fail-on-startup marker |
| 4 | `/api/v1/documents/**` missing from SecurityConfig matchers | HIGH | Sprint 9 (unfixed) |
**Note:** Blocker #3 is already resolved. The current `application.properties:13` reads:
```
cannamanage.security.jwt.secret=${CANNAMANAGE_SECURITY_JWT_SECRET:CHANGE_ME_IN_PRODUCTION_THIS_WILL_FAIL_ON_STARTUP}
```
The `JwtService.validateSecret()` detects this marker and refuses startup. This leaves **3 active blockers**.
### CI/CD Pipeline
The current [`.gitea/workflows/deploy.yml`](.gitea/workflows/deploy.yml:17) triggers on push to `main` and:
1. ✅ Checks out the commit
2. ✅ Builds Docker images
3. ✅ Deploys with `docker compose up -d`
4. ✅ Checks backend health (actuator)
5. ⚠️ Frontend check is non-blocking (doesn't fail the job)
6.**No tests run at all** — neither backend (Maven) nor frontend (Vitest/Playwright)
### Test Coverage
- **Backend:** ~20 tests across cannamanage-api (GlobalExceptionHandlerTest, some controller tests). cannamanage-domain and cannamanage-service have minimal or zero coverage.
- **Frontend:** Playwright integration specs (70+ tests) exist but are never run in CI. No Vitest unit tests in CI either.
- **Sprint 12** delivered the Docker Compose test infrastructure (`docker-compose.test.yml`) — it's ready to be wired into CI.
### Repo Hygiene
| Issue | Location | Impact |
|-------|----------|--------|
| Wrong project name | `cannamanage-frontend/package.json``"name": "shadboard-nextjs-starter-kit"` | Confusing, unprofessional |
| Dead `.github/` folder | `.github/modernize/` | GitHub-specific, project uses Gitea |
| No root README | `.` | No project documentation for new contributors |
| Leftover screenshot scripts | `cannamanage-frontend/*.mjs` | Dev clutter (gitignored PNGs, but scripts committed) |
| SonarQube findings | 7 MAJOR/MINOR issues | Dead fields, generic exceptions, string duplication |
---
## 4. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Document data leak via IDOR | High (any authenticated user) | Critical (DSGVO breach, multi-tenant violation) | Fix #1: Add tenant verification to DocumentController |
| Arbitrary file write via path traversal | Medium (requires auth + upload permission) | High (server compromise) | Fix #2: Sanitize with `FilenameUtils.getName()` |
| Broken deployment from untested code | Medium (no test gate) | High (production outage) | Add test step to deploy.yml |
| Low test confidence for future changes | Ongoing | Medium (regression risk) | Expand backend test suite |
---
## 5. Solution Options
### Option A: Minimal Security Fix Only (2-3 hours)
- Fix 3 remaining security blockers
- No CI/CD changes, no test expansion, no cleanup
- **Pro:** Fastest path to unblock deployment
- **Con:** Leaves test debt and CI risk intact; next feature sprint can introduce regressions
### Option B: Security + CI Quality Gate (4-6 hours)
- Fix 3 security blockers
- Add Maven test + Playwright test steps to CI
- Basic repo cleanup (package.json, README)
- **Pro:** Production-safe deployment pipeline, reasonable effort
- **Con:** Test coverage still low for service/domain layer
### Option C: Full Hardening Sprint (8-12 hours) ⬅️ RECOMMENDED
- Fix 3 security blockers
- Expand backend test suite (target: DocumentService, AuthService, key service methods)
- Wire tests into CI/CD pipeline (fail-fast on test failure)
- CORS configuration externalization
- Login rate limiting
- Complete repo cleanup (README, package.json, dead files, SonarQube fixes)
- **Pro:** Production-ready with confidence, clean repo, future-proof
- **Con:** Full sprint investment (no new features)
---
## 6. Recommendation
**Option C** — This is the right time for a full hardening sprint. The project is feature-complete for MVP, Sprint 12 already built the test infrastructure, and the security blockers have been unfixed since Sprint 9. A half-measure (Option A/B) would leave known technical debt that compounds with every future sprint.
Priority ordering:
1. **Security fixes** (unblocks production) — ~2 hours
2. **CI test gate** (prevents future regressions) — ~2 hours
3. **Backend test expansion** (confidence for the security fixes themselves) — ~4 hours
4. **Repo cleanup + CORS + rate limiting** (polish) — ~2 hours
---
## 7. Open Questions
- [ ] Should CORS allowed origins come from `application.properties` or environment variables?
- [ ] Login rate limiting: Bucket4j (Spring-native) or custom filter with in-memory counter?
- [ ] Target test coverage % for this sprint? (Suggest: cover all security-critical paths = DocumentService, AuthService, TenantFilterAspect)
+337
View File
@@ -0,0 +1,337 @@
# Plan: Sprint 13 — Production Hardening
**Date:** 2026-06-18
**Author:** Patrick Plate / Lumen (Planner)
**Status:** v2 (panel review incorporated)
**Basis:** cannamanage-sprint13-analysis.md
---
## Background
Sprint 13 addresses 3 remaining production-blocking security vulnerabilities (1 was already fixed), wires the existing test infrastructure into CI/CD as a quality gate, expands backend test coverage for security-critical paths, and performs repo cleanup. No new features — pure hardening.
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Security Hardening Layer │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SecurityConfig ──► Role matchers for /api/v1/documents/** │
│ │
│ DocumentController ──► @PreAuthorize + tenant verification │
│ │
│ DocumentService ──► FilenameUtils.getName() sanitization │
│ ──► Explicit clubId check on download/delete │
│ │
├─────────────────────────────────────────────────────────────────┤
│ CI/CD Quality Gate │
├─────────────────────────────────────────────────────────────────┤
│ │
│ deploy.yml ──► [Checkout] → [Test Backend] → [Test Frontend] │
│ ──► [Build Images] → [Deploy] → [Health Check] │
│ │
├─────────────────────────────────────────────────────────────────┤
│ Operational Hardening │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CORS ──► Externalized via application.properties │
│ Rate Limiting ──► Bucket4j on /api/v1/auth/login │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Components
| # | Component | Module | Action |
|---|-----------|--------|--------|
| 1 | DocumentController | cannamanage-api | Add `@PreAuthorize`, tenant verification |
| 2 | DocumentService | cannamanage-service | Sanitize filename, add clubId check |
| 3 | SecurityConfig | cannamanage-api | Add document endpoint matchers |
| 4 | deploy.yml | .gitea/workflows | Add test steps before deployment |
| 5 | DocumentServiceTest | cannamanage-service | New — comprehensive test class |
| 6 | DocumentControllerTest | cannamanage-api | New — security-focused integration tests |
| 7 | AuthServiceTest | cannamanage-api | New — auth flow tests |
| 8 | SecurityConfig | cannamanage-api | Externalize CORS origins |
| 9 | RateLimitFilter | cannamanage-api | New — login rate limiting |
| 10 | package.json | cannamanage-frontend | Fix project name |
| 11 | README.md | root | New — project documentation |
---
## Implementation Steps
### Phase 1: Security Fixes (Priority: CRITICAL)
#### Step 1.1 — Fix IDOR in DocumentController
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/controller/DocumentController.java`
- Add `@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")` on class level
- Inject `SecurityContextHolder` to extract current user's `clubId`
- On `downloadDocument(UUID id)`: after fetching the document, verify `document.getClubId().equals(currentUser.getClubId())`
- On `deleteDocument(UUID id)`: same tenant check + require `ADMIN` or `STAFF` role
- Return `404 Not Found` if tenant mismatch — prevents object enumeration. An attacker should not be able to determine whether a document UUID exists in another tenant.
**Prerequisite:** Verify that portal JWT tokens include the `clubId` claim. If portal tokens lack `clubId`, portal document downloads will 403. Check `PortalAuthService` / `JwtService` token generation to confirm the claim is present before implementing tenant verification.
#### Step 1.2 — Fix Path Traversal in DocumentService
**File:** `cannamanage-service/src/main/java/de/cannamanage/service/DocumentService.java`
- Replace `file.getOriginalFilename()` with `FilenameUtils.getName(file.getOriginalFilename())`
- Add null check: if result is blank, use `"document"` as fallback
- Add dependency on `commons-io` if not already present (it is — used by BankImportService)
- Pattern: consistent with existing [`BankImportService`](cannamanage-service/src/main/java/de/cannamanage/service/bankimport/BankImportService.java) which already does this correctly
#### Step 1.3 — Add Document Endpoint Matchers to SecurityConfig
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
Add explicit matchers in `apiSecurityFilterChain()`:
```java
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
```
Place these BEFORE the `.anyRequest().authenticated()` catch-all.
---
### Phase 2: CI/CD Quality Gate (Priority: HIGH)
#### Step 2.1 — Add Backend Test Step to deploy.yml
**File:** `.gitea/workflows/deploy.yml`
Insert a new step after checkout, before Docker build:
```yaml
- name: Run backend tests
run: |
set -euo pipefail
cd cannamanage-api
mvn test --batch-mode -f pom.xml
```
**Note:** Maven is available in the runner image. The Maven Wrapper (`mvnw`) is not committed to this repo.
This runs all backend tests. If any test fails, the deploy is aborted.
#### Step 2.2 — Add Frontend Lint/Type-Check Step
**Prerequisite:** Add `"type-check": "tsc --noEmit"` to `cannamanage-frontend/package.json` scripts.
Insert after backend tests:
```yaml
- name: Frontend type check
run: |
set -euo pipefail
cd cannamanage-frontend
corepack enable
pnpm install --frozen-lockfile
pnpm run lint
pnpm run type-check
```
**Note:** Full Playwright integration tests are too heavy for every push (require Docker-in-Docker). Keep those for manual/nightly runs via `docker-compose.test.yml`.
#### Step 2.3 — Make Frontend Health Check Blocking
**File:** `.gitea/workflows/deploy.yml`
Change the frontend verify step to `exit 1` on failure instead of just logging a warning.
---
### Phase 3: Backend Test Expansion (Priority: HIGH)
#### Step 3.1 — DocumentServiceTest (new)
**File:** `cannamanage-service/src/test/java/de/cannamanage/service/DocumentServiceTest.java`
Test cases:
- `testUploadDocument_sanitizesFilename()` — verify path traversal attempt is neutralized
- `testUploadDocument_nullFilename_usesFallback()` — null filename → "document"
- `testUploadDocument_validFilename_preserved()` — normal filename passes through
- `testDownloadDocument_wrongTenant_throwsForbidden()` — tenant isolation
- `testDownloadDocument_correctTenant_returnsContent()` — happy path
- `testDeleteDocument_wrongTenant_throwsForbidden()` — tenant isolation on delete
- `testDeleteDocument_adminRole_succeeds()` — admin can delete
#### Step 3.2 — DocumentControllerSecurityTest (new)
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/controller/DocumentControllerSecurityTest.java`
Integration tests using `@WebMvcTest` + `@WithMockUser`:
- `testDownload_unauthenticated_returns401()`
- `testDownload_wrongTenant_returns403()`
- `testDownload_correctTenant_returns200()`
- `testDelete_memberRole_returns403()` — MEMBER cannot delete
- `testDelete_staffRole_returns200()` — STAFF can delete
- `testUpload_memberRole_returns403()` — MEMBER cannot upload
- `testUpload_staffRole_returns200()`
#### Step 3.3 — AuthServiceTest (new)
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/service/AuthServiceTest.java`
- `testLogin_validCredentials_returnsTokenPair()`
- `testLogin_invalidPassword_throws401()`
- `testLogin_nonExistentUser_throws401()`
- `testRefreshToken_validToken_returnsNewAccess()`
- `testRefreshToken_expired_throws401()`
- `testSha256_consistent()` — hashing determinism
#### Step 3.4 — SecurityConfigTest (new)
**File:** `cannamanage-api/src/test/java/de/cannamanage/api/security/SecurityConfigTest.java`
Verify URL pattern matching:
- `testDocumentEndpoints_requireAuthentication()`
- `testAuthEndpoints_arePublic()`
- `testActuatorHealth_isPublic()`
---
### Phase 4: Operational Hardening (Priority: MEDIUM)
#### Step 4.1 — Externalize CORS Configuration
**File:** `cannamanage-api/src/main/resources/application.properties`
Add:
```properties
cannamanage.cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000}
```
**File:** `cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java`
Replace hardcoded origins with `@Value("${cannamanage.cors.allowed-origins}")` and split on comma.
#### Step 4.2 — Login Rate Limiting
Add Bucket4j dependency to `cannamanage-api/pom.xml`:
```xml
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
```
Add Caffeine dependency to `cannamanage-api/pom.xml`:
```xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
```
Create `cannamanage-api/src/main/java/de/cannamanage/api/security/LoginRateLimitFilter.java`:
- Use Caffeine cache (TTL-based eviction) instead of raw ConcurrentHashMap
- Key: IP address, Value: Bucket
- Max entries: 10,000
- TTL: 10 minutes (auto-evicts stale entries, prevents memory leak under DDoS)
- Limit: 5 attempts per minute per IP
- Applies only to `POST /api/v1/auth/login`
- Returns `429 Too Many Requests` with `Retry-After` header when exceeded
- Register in SecurityConfig filter chain
---
### Phase 5: Repo Cleanup (Priority: LOW)
#### Step 5.1 — Fix package.json Project Name
**File:** `cannamanage-frontend/package.json`
Change `"name": "shadboard-nextjs-starter-kit"``"name": "cannamanage-frontend"`
#### Step 5.2 — Remove Dead .github Directory
```bash
rm -rf .github/
```
Only if `.github/` contains GitHub-specific config (Actions, Dependabot) that doesn't apply to Gitea.
#### Step 5.3 — Create Root README
**File:** `README.md`
Minimal project README: what it is, how to run locally, how to deploy, architecture overview.
#### Step 5.4 — Clean Up Leftover Screenshot Scripts
Remove one-shot `.mjs` scripts from `cannamanage-frontend/`:
- `upload-dialog-screenshot.mjs`
- `sprint12-final.mjs`
- `sprint12-v2.mjs`
These were development utilities, not part of the test suite.
#### Step 5.5 — Fix SonarQube Findings
Address 7 MAJOR/MINOR findings from the security review:
- Remove unused `auditService` field in DocumentService (or wire it up for audit logging)
- Replace generic `throw new Exception()` in Camt053Parser with specific exception
- Replace generic `RuntimeException` in AuthService.sha256() with custom exception
- Extract duplicated `"Invalid credentials"` string to constant
- Fix static access via instance in Camt053Parser
---
## Dependency Order
```
Step 1.1 ──┐
Step 1.2 ──┼──► Step 3.1 (tests verify the fixes)
Step 1.3 ──┘ │
Step 3.2 (controller security tests)
Step 2.1 (CI runs these tests)
Step 2.2
Step 2.3
Step 4.1, 4.2 (operational hardening)
Step 5.15.5 (cleanup, parallel)
```
---
## Acceptance Criteria
1. ✅ No authenticated user can download/delete documents from another tenant
2. ✅ Path traversal filenames are sanitized before storage
3.`/api/v1/documents/**` has explicit role matchers in SecurityConfig
4. ✅ CI pipeline runs backend tests before deployment (fails on test failure)
5. ✅ CI pipeline runs frontend lint + type-check before deployment
6. ✅ At least 15 new backend tests covering security-critical paths
7. ✅ CORS origins configurable via environment variable
8. ✅ Login endpoint rate-limited (5 attempts/min/IP)
9.`package.json` has correct project name
10. ✅ Root README exists
---
## Estimated Effort
| Phase | Effort | Cumulative |
|-------|--------|------------|
| Phase 1: Security Fixes | ~2h | 2h |
| Phase 2: CI Quality Gate | ~1.5h | 3.5h |
| Phase 3: Backend Tests | ~3h | 6.5h |
| Phase 4: Operational Hardening | ~2h | 8.5h |
| Phase 5: Repo Cleanup | ~1h | 9.5h |
**Total: ~9.5 hours** (full sprint day)
@@ -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