586 lines
17 KiB
Markdown
586 lines
17 KiB
Markdown
# 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
|