Compare commits
1 Commits
fix/race-i
...
fix/exclud
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b39d3c3c9 |
@@ -219,11 +219,9 @@ jobs:
|
|||||||
export DLC_DATABASE_PASSWORD=postgres
|
export DLC_DATABASE_PASSWORD=postgres
|
||||||
export DLC_DATABASE_NAME=dance_lessons_coach_bdd_test
|
export DLC_DATABASE_NAME=dance_lessons_coach_bdd_test
|
||||||
export DLC_DATABASE_SSL_MODE=disable
|
export DLC_DATABASE_SSL_MODE=disable
|
||||||
# T12: per-package isolated Postgres schema with migrations (re-enables what
|
# Enable per-scenario schema isolation (ADR-0025) to prevent flaky AuthBDD failures.
|
||||||
# PR #26 attempted but couldn't deliver because the empty schemas had no tables).
|
# Without this, scenarios share the public schema and pollute each other's state.
|
||||||
# The fix: testserver Start() now builds a per-package isolated repo via
|
# Observed flakiness: same code passes in #605, fails in #606 on TestAuthBDD/*.
|
||||||
# user.NewPostgresRepositoryFromDSN which DOES run AutoMigrate against the new
|
|
||||||
# schema. Packages then run in parallel (~2.85x speedup observed locally).
|
|
||||||
export BDD_SCHEMA_ISOLATION=true
|
export BDD_SCHEMA_ISOLATION=true
|
||||||
./scripts/run-bdd-tests.sh
|
./scripts/run-bdd-tests.sh
|
||||||
|
|
||||||
@@ -299,13 +297,7 @@ jobs:
|
|||||||
# Check for version bump on main branch
|
# Check for version bump on main branch
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
echo "🔖 Checking for version bump..."
|
echo "🔖 Checking for version bump..."
|
||||||
# Read commit message from git, NOT from the workflow event payload.
|
./scripts/ci-version-bump.sh "${{ github.event.head_commit.message }}" --no-push
|
||||||
# The event-payload expression is interpolated literally into the
|
|
||||||
# rendered script (even inside comments — see PR #38 + #46), so any
|
|
||||||
# backtick / unbalanced quote / multi-line body breaks bash parsing.
|
|
||||||
# git log is interpolation-free and safe.
|
|
||||||
COMMIT_MSG=$(git log -1 --pretty=%B)
|
|
||||||
./scripts/ci-version-bump.sh "$COMMIT_MSG" --no-push
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Single push for all commits (this is the ONLY push in the entire workflow)
|
# Single push for all commits (this is the ONLY push in the entire workflow)
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,6 +42,5 @@ frontend/.output/
|
|||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.env
|
frontend/.env
|
||||||
frontend/.cache/
|
frontend/.cache/
|
||||||
frontend/storybook-static/
|
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
frontend/playwright-report/
|
frontend/playwright-report/
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Combine BDD and Swagger-based testing
|
# Combine BDD and Swagger-based testing
|
||||||
|
|
||||||
**Status:** Implemented (BDD + OpenAPI documentation operational; SDK generation explicitly out of scope — would require a fresh ADR if reopened)
|
**Status:** Partially Implemented (BDD + Documentation only)
|
||||||
**Authors:** Gabriel Radureau, AI Agent
|
**Authors:** Gabriel Radureau, AI Agent
|
||||||
**Date:** 2026-04-05
|
**Date:** 2026-04-05
|
||||||
**Last Updated:** 2026-05-05
|
**Last Updated:** 2026-04-05
|
||||||
|
**Implementation Status:** BDD testing and OpenAPI documentation completed, SDK generation deferred
|
||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ Chosen option: "Hybrid approach" because it provides the best combination of beh
|
|||||||
|
|
||||||
## Implementation Status
|
## Implementation Status
|
||||||
|
|
||||||
**Status**: ✅ Implemented (BDD + OpenAPI documentation operational; SDK generation explicitly out of scope)
|
**Status**: ✅ Partially Implemented (BDD + Documentation only)
|
||||||
|
|
||||||
### What We Actually Have
|
### What We Actually Have
|
||||||
|
|
||||||
@@ -328,7 +329,7 @@ If we need SDK generation in the future:
|
|||||||
- Add SDK-based BDD tests
|
- Add SDK-based BDD tests
|
||||||
- Implement true hybrid testing approach
|
- Implement true hybrid testing approach
|
||||||
|
|
||||||
**Current Status:** ✅ Implemented (BDD + OpenAPI documentation; SDK generation out of scope)
|
**Current Status:** ✅ Partially Implemented (BDD + Documentation)
|
||||||
**BDD Tests:** http://localhost:8080/api/health (all passing)
|
**BDD Tests:** http://localhost:8080/api/health (all passing)
|
||||||
**OpenAPI Docs:** http://localhost:8080/swagger/
|
**OpenAPI Docs:** http://localhost:8080/swagger/
|
||||||
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# 13. OpenAPI/Swagger Toolchain Selection
|
# 13. OpenAPI/Swagger Toolchain Selection
|
||||||
|
|
||||||
**Date:** 2026-04-05
|
**Date:** 2026-04-05
|
||||||
**Status:** Implemented (OpenAPI documentation operational; SDK generation explicitly out of scope, see ADR-0009)
|
**Status:** Partially Implemented (Documentation only)
|
||||||
**Authors:** Arcodange Team
|
**Authors:** Arcodange Team
|
||||||
**Implementation Date:** 2026-04-05
|
**Implementation Date:** 2026-04-05
|
||||||
**Last Updated:** 2026-05-05
|
**Last Updated:** 2026-04-05
|
||||||
|
**Status:** OpenAPI documentation operational, SDK generation deferred
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -982,7 +983,7 @@ If we need SDK generation in the future:
|
|||||||
4. Implement request validation middleware
|
4. Implement request validation middleware
|
||||||
5. Migrate to OpenAPI 3.0 if needed
|
5. Migrate to OpenAPI 3.0 if needed
|
||||||
|
|
||||||
**Current Status:** ✅ Implemented (OpenAPI documentation; SDK generation out of scope)
|
**Current Status:** ✅ Partially Implemented (Documentation only)
|
||||||
**Implementation:** swaggo/swag with embedded documentation
|
**Implementation:** swaggo/swag with embedded documentation
|
||||||
**Documentation:** http://localhost:8080/swagger/
|
**Documentation:** http://localhost:8080/swagger/
|
||||||
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
**OpenAPI Spec:** http://localhost:8080/swagger/doc.json
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 18. User Management and Authentication System
|
# 18. User Management and Authentication System
|
||||||
|
|
||||||
**Date:** 2026-04-06
|
**Date:** 2026-04-06
|
||||||
**Status:** Implemented (user model, JWT auth, password-reset workflow, admin endpoints, greet personalization, BDD coverage all live; future enhancements like 2FA / email verification belong in separate ADRs)
|
**Status:** Partially Implemented
|
||||||
**Authors:** Product Owner
|
**Authors:** Product Owner
|
||||||
**Decision Drivers:** Security, User Personalization, Admin Functionality
|
**Decision Drivers:** Security, User Personalization, Admin Functionality
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 19. PostgreSQL Database Integration
|
# 19. PostgreSQL Database Integration
|
||||||
|
|
||||||
**Date:** 2026-04-07
|
**Date:** 2026-04-07
|
||||||
**Status:** Implemented (core integration; performance tuning + extended monitoring tracked as future work)
|
**Status:** Partially Implemented
|
||||||
**Authors:** Product Owner
|
**Authors:** Product Owner
|
||||||
**Decision Drivers:** Data Persistence, Scalability, Production Readiness
|
**Decision Drivers:** Data Persistence, Scalability, Production Readiness
|
||||||
|
|
||||||
@@ -671,10 +671,10 @@ func AfterScenario(ctx context.Context, sc *godog.Scenario, err error) (context.
|
|||||||
## Future Considerations
|
## Future Considerations
|
||||||
|
|
||||||
### Immediate Next Steps (Post-Migration)
|
### Immediate Next Steps (Post-Migration)
|
||||||
1. **CI/CD Integration:** Add PostgreSQL to CI pipeline — ✅ Implemented (`postgres:15` service in `.gitea/workflows/ci-cd.yaml`, all BDD tests run against real Postgres)
|
1. **CI/CD Integration:** Add PostgreSQL to CI pipeline
|
||||||
2. **Performance Tuning:** Query optimization — Deferred. No production hot path identified. Reopen as separate ADR if/when latency budget exceeded.
|
2. **Performance Tuning:** Query optimization
|
||||||
3. **Monitoring:** Database health metrics — Partial. `/api/healthz` reports DB connectivity. Deeper metrics (slow query log, pool stats) deferred until ADR-0022 cache Phase 2 lands.
|
3. **Monitoring:** Database health metrics
|
||||||
4. **Backup Strategy:** Regular database backups — Deferred. No production data yet. Will require separate ADR before any production data lands.
|
4. **Backup Strategy:** Regular database backups
|
||||||
|
|
||||||
### Long-Term Enhancements
|
### Long-Term Enhancements
|
||||||
1. **Database Sharding:** For horizontal scaling
|
1. **Database Sharding:** For horizontal scaling
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 21. JWT Secret Retention Policy
|
# 10. JWT Secret Retention Policy
|
||||||
|
|
||||||
**Status:** Implemented (2026-05-05 — `pkg/user/jwt_manager.go` `RemoveExpiredSecrets` + `StartCleanupLoop`, wired in `pkg/server/server.go` `Run`; admin endpoint `/api/v1/admin/jwt/secrets` remains explicitly out of scope and tracked under @todo BDD scenarios)
|
**Status:** Proposed
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# Config Hot Reloading Strategy
|
# Config Hot Reloading Strategy
|
||||||
|
|
||||||
**Status:** Phase 1+2 Implemented (2026-05-05 — `logging.level` and `auth.jwt.ttl` hot-reloadable via `Config.WatchAndApply` in `pkg/config/config.go`, wired in `pkg/server/server.go Run`. Phase 2 also fixed a pre-existing bug where the hardcoded 24h TTL ignored `auth.jwt.ttl` from config entirely.) Phase 3 sub-phase 3.1 Implemented (2026-05-05 — `ReconfigureTracerProvider` in `pkg/telemetry/telemetry.go` added). Phase 3 sub-phase 3.2 In Flight (2026-05-05 — `telemetry.sampler.type` + `telemetry.sampler.ratio` hot-reload via `SetSamplerReconfigureCallback` in `pkg/config/config.go`. Remaining field: `api.v2_enabled`.)
|
**Status:** Proposed
|
||||||
**Authors:** Gabriel Radureau, AI Agent
|
**Authors:** Gabriel Radureau, AI Agent
|
||||||
**Date:** 2026-04-05
|
**Date:** 2026-04-05
|
||||||
**Last Updated:** 2026-05-05
|
|
||||||
|
|
||||||
## Context and Problem Statement
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR 0024: BDD Test Organization and Isolation Strategy
|
# ADR 0024: BDD Test Organization and Isolation Strategy
|
||||||
|
|
||||||
**Status:** Implemented (Phase 1 + Phase 2 + Phase 3 — parallel testing via [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35), isolation strategy detailed in [ADR-0025](0025-bdd-scenario-isolation-strategies.md))
|
**Status:** Partially Implemented
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
@@ -284,22 +284,20 @@ func CleanupFeatureData(featureName string) {
|
|||||||
|
|
||||||
## Implementation Plan
|
## Implementation Plan
|
||||||
|
|
||||||
### Phase 1: Refactor Current Tests — ✅ Implemented
|
### Phase 1: Refactor Current Tests (1-2 weeks)
|
||||||
1. Split monolithic feature files into feature directories — done (see `features/<domain>/` layout)
|
1. Split monolithic feature files into feature directories
|
||||||
2. Create feature-specific test scripts — done
|
2. Create feature-specific test scripts
|
||||||
3. Implement basic isolation (config files, database names) — done
|
3. Implement basic isolation (config files, database names)
|
||||||
|
|
||||||
### Phase 2: Enhance Test Infrastructure — ✅ Implemented
|
### Phase 2: Enhance Test Infrastructure (2-3 weeks)
|
||||||
1. Add synchronization helpers to test framework — done
|
1. Add synchronization helpers to test framework
|
||||||
2. Implement server lifecycle management — done (`pkg/bdd/testserver/server.go`)
|
2. Implement server lifecycle management
|
||||||
3. Create comprehensive cleanup routines — done
|
3. Create comprehensive cleanup routines
|
||||||
|
|
||||||
### Phase 3: Parallel Testing — ✅ Implemented (PR #35, 2026-05-03)
|
### Phase 3: Parallel Testing (Optional)
|
||||||
1. Add parallel test execution capability — done (schema-per-package isolation, **2.85x speedup**)
|
1. Add parallel test execution capability
|
||||||
2. Implement port management for parallel runs — done (`pkg/bdd/parallel/port_manager.go`)
|
2. Implement port management for parallel runs
|
||||||
3. Add resource monitoring — deferred (not blocking; can be reopened as separate ADR if/when CI flakiness re-emerges)
|
3. Add resource monitoring
|
||||||
|
|
||||||
The strategy choice between alternatives (TRUNCATE vs schema isolation vs container-per-test) is documented in [ADR-0025](0025-bdd-scenario-isolation-strategies.md). Default behavior in CI is `BDD_SCHEMA_ISOLATION=true` (cf. `documentation/BDD_TEST_ENV.md`).
|
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR 0025: BDD Scenario Isolation Strategies
|
# ADR 0025: BDD Scenario Isolation Strategies
|
||||||
|
|
||||||
**Status:** Implemented (per-package schema isolation since T12 stage 2/2 - 2026-05-03)
|
**Status:** Partially Implemented
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
# ADR 0026: Composite Info Endpoint vs Separate Calls
|
|
||||||
|
|
||||||
**Status:** Implemented (2026-05-05 — PR pending)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The application currently exposes several endpoints that provide system information:
|
|
||||||
- `/api/version` - returns version, commit, build date, Go version (cached 60s)
|
|
||||||
- `/api/health` - returns `{"status":"healthy"}` (simple liveness)
|
|
||||||
- `/api/healthz` - returns rich health info: status, version, uptime_seconds, timestamp
|
|
||||||
- `/api/ready` - returns readiness with connection details
|
|
||||||
|
|
||||||
Frontend components like `HealthDashboard` currently call `/api/healthz` to display server info. However, there is a need for a **composite endpoint** that aggregates:
|
|
||||||
1. Version information (from `/api/version`)
|
|
||||||
2. Build metadata (commit hash, build date)
|
|
||||||
3. Uptime information (from `/api/healthz`)
|
|
||||||
4. Cache status (enabled/disabled)
|
|
||||||
5. Health status
|
|
||||||
|
|
||||||
This raises an architectural question: **Should we create a new composite `/api/info` endpoint, or should frontend components make multiple separate API calls?**
|
|
||||||
|
|
||||||
### The Problem with Separate Calls
|
|
||||||
|
|
||||||
If the frontend makes individual calls to `/api/version`, `/api/healthz`, and checks cache config separately:
|
|
||||||
|
|
||||||
1. **Multiple network requests**: 3-4 HTTP round trips per page load
|
|
||||||
2. **Inconsistent data**: Responses may come from different moments in time
|
|
||||||
3. **No caching coordination**: Each endpoint has its own cache key and TTL
|
|
||||||
4. **Complex frontend logic**: Need to merge data from multiple sources
|
|
||||||
5. **Poor user experience**: Slower page loads, multiple loading states
|
|
||||||
|
|
||||||
### Current State Analysis
|
|
||||||
|
|
||||||
| Endpoint | Data Provided | Cache TTL | Use Case |
|
|
||||||
|----------|---------------|-----------|----------|
|
|
||||||
| `/api/version` | version, commit, built, go | 60s | Version info |
|
|
||||||
| `/api/healthz` | status, version, uptime_seconds, timestamp | None | K8s probes, health dashboard |
|
|
||||||
| `/api/health` | status: "healthy" | None | Simple liveness |
|
|
||||||
| `/api/ready` | ready, connections, reason | None | Readiness probes |
|
|
||||||
|
|
||||||
The `/api/healthz` endpoint already combines some data (status + version + uptime + timestamp), but it:
|
|
||||||
- Doesn't include commit_short
|
|
||||||
- Doesn't include build_date separately
|
|
||||||
- Doesn't include cache_enabled
|
|
||||||
- Is not cached
|
|
||||||
- Has Kubernetes-specific field naming (`healthz`)
|
|
||||||
|
|
||||||
## Decision Drivers
|
|
||||||
|
|
||||||
* **Performance**: Minimize network round trips for frontend
|
|
||||||
* **Consistency**: All data should reflect the same point-in-time
|
|
||||||
* **Maintainability**: Single source of truth for system info
|
|
||||||
* **Caching**: Reuse existing cache infrastructure (ADR-0022)
|
|
||||||
* **API Design**: Follow REST principles and existing patterns
|
|
||||||
* **Backward Compatibility**: Existing endpoints must remain unchanged
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
### Option 1: Composite `/api/info` Endpoint (Chosen)
|
|
||||||
|
|
||||||
Create a new endpoint that aggregates all required data in a single call.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- ✅ Single network request for frontend
|
|
||||||
- ✅ Consistent point-in-time data
|
|
||||||
- ✅ Can leverage existing cache infrastructure with key `info:json`
|
|
||||||
- ✅ Follows existing pattern of `/api/version` caching
|
|
||||||
- ✅ Clean API design - one endpoint, one purpose
|
|
||||||
- ✅ Reduces frontend complexity
|
|
||||||
- ✅ Better UX - faster page loads
|
|
||||||
- ✅ Aligns with ADR-0022 cache strategy (reusable cache key pattern)
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- ⚠️ Duplicates some data from `/api/healthz` and `/api/version`
|
|
||||||
- ⚠️ Requires new endpoint implementation
|
|
||||||
- ⚠️ Need to maintain consistency if source endpoints change
|
|
||||||
|
|
||||||
### Option 2: Frontend Aggregation with Multiple Calls
|
|
||||||
|
|
||||||
Frontend makes separate calls to `/api/version`, `/api/healthz`, and introspects config.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- ✅ No backend changes required
|
|
||||||
- ✅ Uses existing endpoints
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- ❌ Multiple network requests (3-4 round trips)
|
|
||||||
- ❌ Inconsistent data timing
|
|
||||||
- ❌ Complex error handling in frontend
|
|
||||||
- ❌ Poor UX - multiple loading states, slower
|
|
||||||
- ❌ Each endpoint has different caching behavior
|
|
||||||
- ❌ Violates DRY - same data fetched multiple times
|
|
||||||
|
|
||||||
### Option 3: Extend `/api/healthz` Endpoint
|
|
||||||
|
|
||||||
Add `commit_short`, `build_date`, and `cache_enabled` fields to existing `/api/healthz`.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- ✅ Reuses existing endpoint
|
|
||||||
- ✅ Single request
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- ❌ Breaks backward compatibility (response schema change)
|
|
||||||
- ❌ `/api/healthz` is Kubernetes-focused (naming convention)
|
|
||||||
- ❌ Not cached currently
|
|
||||||
- ❌ Mixes health probe concerns with version info
|
|
||||||
- ❌ Violates single responsibility
|
|
||||||
|
|
||||||
### Option 4: GraphQL / Query Parameters
|
|
||||||
|
|
||||||
Allow clients to specify which fields they want via query parameters.
|
|
||||||
|
|
||||||
**Pros:**
|
|
||||||
- ✅ Flexible - clients get exactly what they need
|
|
||||||
- ✅ Single endpoint
|
|
||||||
|
|
||||||
**Cons:**
|
|
||||||
- ❌ Overkill for this use case
|
|
||||||
- ❌ Not consistent with existing REST API design
|
|
||||||
- ❌ Complex implementation
|
|
||||||
- ❌ Not aligned with project architecture (Chi router, REST style)
|
|
||||||
|
|
||||||
## Decision Outcome
|
|
||||||
|
|
||||||
**Chosen: Option 1 - Composite `/api/info` Endpoint**
|
|
||||||
|
|
||||||
We will implement a new `GET /api/info` endpoint that returns a JSON object with all required fields in a single call. This endpoint will:
|
|
||||||
|
|
||||||
1. Aggregate data from existing sources (`version` package, `config`, server uptime)
|
|
||||||
2. Be cached using the existing cache service with key `info:json`
|
|
||||||
3. Use TTL from `config.cache.default_ttl_seconds` (consistent with ADR-0022)
|
|
||||||
4. Return `X-Cache: HIT/MISS` headers for debugging
|
|
||||||
5. Follow existing Go handler patterns from `pkg/server/server.go`
|
|
||||||
|
|
||||||
### Response Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "1.4.0",
|
|
||||||
"commit_short": "a3f7b2c1",
|
|
||||||
"build_date": "2026-05-04T08:00:00Z",
|
|
||||||
"uptime_seconds": 1234,
|
|
||||||
"cache_enabled": true,
|
|
||||||
"healthz_status": "healthy"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
1. **Performance**: Single HTTP request instead of 3-4 separate calls
|
|
||||||
2. **Consistency**: All data reflects the same moment in time
|
|
||||||
3. **Caching**: Leverages existing cache infrastructure (ADR-0022) with predictable key pattern
|
|
||||||
4. **API Design**: Clean, RESTful endpoint with single responsibility
|
|
||||||
5. **Maintainability**: Clear separation of concerns - info aggregation is a distinct use case
|
|
||||||
6. **Backward Compatibility**: Existing endpoints remain unchanged
|
|
||||||
7. **Frontend Simplicity**: Reduces complexity and improves UX
|
|
||||||
|
|
||||||
### Cache Strategy
|
|
||||||
|
|
||||||
Following ADR-0022 pattern:
|
|
||||||
- Cache key: `info:json` (consistent with `version:format` pattern)
|
|
||||||
- TTL: `config.cache.default_ttl_seconds` (default 300 seconds)
|
|
||||||
- Cache service: `pkg/cache/cache.go` InMemoryService
|
|
||||||
- Headers: `X-Cache: HIT` or `X-Cache: MISS`
|
|
||||||
|
|
||||||
This allows the endpoint to be fast even under load, while maintaining data freshness.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
1. **Improved frontend performance**: Single request instead of multiple
|
|
||||||
2. **Better UX**: Faster page loads, simpler loading states
|
|
||||||
3. **Consistent data**: All fields reflect the same point-in-time
|
|
||||||
4. **Cache efficiency**: Reuses existing cache infrastructure
|
|
||||||
5. **Clean separation**: Info endpoint handles aggregation, source endpoints unchanged
|
|
||||||
6. **Easy to test**: Single endpoint with predictable response
|
|
||||||
|
|
||||||
### Negative
|
|
||||||
|
|
||||||
1. **Data duplication**: Some fields appear in multiple endpoints
|
|
||||||
2. **Maintenance burden**: If source data changes, endpoint must be updated
|
|
||||||
3. **New endpoint**: Increases API surface area (though minimal)
|
|
||||||
|
|
||||||
### Mitigation
|
|
||||||
|
|
||||||
1. Data duplication is acceptable - it's read-only system info
|
|
||||||
2. Source the data from the same packages/functions used by other endpoints
|
|
||||||
3. The new endpoint has a clear, focused purpose
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- [ADR-0002: Chi Router](adr/0002-chi-router.md) - Routing foundation
|
|
||||||
- [ADR-0022: Rate Limiting Cache Strategy](adr/0022-rate-limiting-cache-strategy.md) - Cache pattern reference
|
|
||||||
- [pkg/server/server.go](pkg/server/server.go) - Handler patterns
|
|
||||||
- [pkg/cache/cache.go](pkg/cache/cache.go) - Cache service
|
|
||||||
- [pkg/version/version.go](pkg/version/version.go) - Version data source
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# API endpoints
|
|
||||||
|
|
||||||
Reference document for all HTTP endpoints exposed by `dance-lessons-coach` server. The authoritative source is the swag-generated Swagger UI at `/swagger/index.html` (served by the Go binary). This markdown is the human-readable index, intentionally short — when in doubt, run the server and open Swagger.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- All paths under `/api/` (no other prefix is used)
|
|
||||||
- Versioned API under `/api/v1/<resource>` and `/api/v2/<resource>` (cf. ADR-0010 v2 feature flag)
|
|
||||||
- System / Health / Version endpoints at root (`/api/<endpoint>`, no version)
|
|
||||||
- Admin endpoints under `/api/admin/<action>` (require master admin password header)
|
|
||||||
- Response Content-Type: `application/json` unless documented otherwise
|
|
||||||
- Error envelope: `{"error":"<code>","message":"<text>"}` (HTTP 4xx/5xx)
|
|
||||||
|
|
||||||
## System endpoints (no auth)
|
|
||||||
|
|
||||||
| Method | Path | Purpose | Cf. |
|
|
||||||
|---|---|---|---|
|
|
||||||
| GET | `/api/health` | Liveness check (legacy, returns `{"status":"healthy"}`) | `pkg/server/server.go` |
|
|
||||||
| GET | `/api/healthz` | **Kubernetes-style** rich health: status / version / uptime_seconds / timestamp | PR #20 — handler with swag `@Router /healthz [get]` |
|
|
||||||
| GET | `/api/ready` | Readiness check (DB connection + service deps) | `pkg/server/server.go handleReadiness` |
|
|
||||||
| GET | `/api/version` | Version info (cached 60s, since PR #29) | `pkg/server/server.go handleVersion` |
|
|
||||||
| GET | `/api/info` | **Composite info aggregator**: version / commit_short / build_date / uptime_seconds / cache_enabled / healthz_status. Cached when cache is enabled (X-Cache: HIT/MISS header) | ADR-0026 — `pkg/server/server.go handleInfo` |
|
|
||||||
|
|
||||||
`/api/info` body schema (`InfoResponse`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"commit_short": "abc12345",
|
|
||||||
"build_date": "2026-05-05",
|
|
||||||
"uptime_seconds": 1234,
|
|
||||||
"cache_enabled": true,
|
|
||||||
"healthz_status": "healthy"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `/api/info` from a frontend footer or status page when you need version + uptime + cache state in a single round trip. The composite design avoids 3-4 chatty calls (`/version`, `/healthz`, `/ready`) when only a snapshot is needed.
|
|
||||||
|
|
||||||
`/api/healthz` body schema (`HealthzResponse`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "healthy",
|
|
||||||
"version": "1.4.0",
|
|
||||||
"uptime_seconds": 1234,
|
|
||||||
"timestamp": "2026-05-04T08:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `/api/healthz` for kubelet liveness probes — richer than `/api/health` and stable.
|
|
||||||
|
|
||||||
## Admin endpoints (require X-Admin-Password header)
|
|
||||||
|
|
||||||
| Method | Path | Purpose | Cf. |
|
|
||||||
|---|---|---|---|
|
|
||||||
| POST | `/api/admin/cache/flush` | Flush the entire in-memory cache. Returns `{"flushed":true,"items_flushed":N,"timestamp":"..."}` (200) or `{"error":"unauthorized"}` (401) or `{"error":"cache_disabled"}` (503) | PR #29 — `pkg/server/server.go handleAdminCacheFlush` |
|
|
||||||
|
|
||||||
Auth: header `X-Admin-Password: <master-password>` (matches `auth.admin_master_password` in config / `DLC_AUTH_ADMIN_MASTER_PASSWORD` env var). Default `admin123` for local dev — **change in production**.
|
|
||||||
|
|
||||||
## v1 API (auth + greeting)
|
|
||||||
|
|
||||||
Mounted at `/api/v1/...` with the rate-limit middleware (cf. ADR-0022 Phase 1, since PR #22). Cached responses on greet (since PR #29).
|
|
||||||
|
|
||||||
### Auth (`/api/v1/auth/...`)
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| POST | `/api/v1/auth/register` | User registration |
|
|
||||||
| POST | `/api/v1/auth/login` | Login with username + password, returns JWT |
|
|
||||||
| POST | `/api/v1/auth/validate` | Validate a JWT token |
|
|
||||||
| POST | `/api/v1/auth/password-reset/request` | Request password reset (admin-flagged users only) |
|
|
||||||
| POST | `/api/v1/auth/password-reset/complete` | Complete password reset |
|
|
||||||
|
|
||||||
JWT secret rotation policies: cf. ADR-0021 + JWT secrets endpoints under `/api/v1/admin/jwt/secrets` (admin-only).
|
|
||||||
|
|
||||||
### Greet (`/api/v1/greet/...`)
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| GET | `/api/v1/greet?name=X` | Greeting (cached per name 60s, header `X-Cache: HIT/MISS`) |
|
|
||||||
| GET | `/api/v1/greet/{name}` | Greeting (path param variant, same caching) |
|
|
||||||
|
|
||||||
### Admin under v1 (`/api/v1/admin/...`)
|
|
||||||
|
|
||||||
JWT secret management endpoints. Cf. swag annotations in handlers + features/jwt/ BDD scenarios for the exact contract.
|
|
||||||
|
|
||||||
## v2 API
|
|
||||||
|
|
||||||
Enabled via `api.v2_enabled` config (cf. ADR-0010 v2 feature flag).
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| POST | `/api/v2/greet` | v2 greeting (JSON body, more validation) |
|
|
||||||
|
|
||||||
## Swagger UI
|
|
||||||
|
|
||||||
Served at `/swagger/index.html` (and `/swagger/doc.json` for the embedded spec). Always reflects what the running binary exposes — when in doubt, prefer Swagger over this markdown.
|
|
||||||
|
|
||||||
## Cross-references
|
|
||||||
|
|
||||||
- [ADR-0002](../adr/0002-chi-router.md) — Chi router choice
|
|
||||||
- [ADR-0010](../adr/0010-api-v2-feature-flag.md) — v2 feature flag
|
|
||||||
- [ADR-0013](../adr/0013-openapi-swagger-toolchain.md) — OpenAPI / Swagger toolchain
|
|
||||||
- [ADR-0018](../adr/0018-user-management-auth-system.md) — User management & auth
|
|
||||||
- [ADR-0021](../adr/0021-jwt-secret-retention-policy.md) — JWT secret retention
|
|
||||||
- [ADR-0022](../adr/0022-rate-limiting-cache-strategy.md) — Rate limiting + cache
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# BDD test environment
|
|
||||||
|
|
||||||
Environment variables and tooling specific to running BDD scenarios locally and in CI. Companion to [BDD_GUIDE.md](BDD_GUIDE.md) (which covers the BDD authoring workflow itself).
|
|
||||||
|
|
||||||
## Required env vars (database connection)
|
|
||||||
|
|
||||||
The BDD test server needs a Postgres instance reachable via:
|
|
||||||
|
|
||||||
| Var | Default | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `DLC_DATABASE_HOST` | `localhost` | Host of the Postgres instance |
|
|
||||||
| `DLC_DATABASE_PORT` | `5432` | |
|
|
||||||
| `DLC_DATABASE_USER` | `postgres` | Test-only credentials (NOT production) |
|
|
||||||
| `DLC_DATABASE_PASSWORD` | `postgres` | |
|
|
||||||
| `DLC_DATABASE_NAME` | `dance_lessons_coach_bdd_test` | Dedicated test DB |
|
|
||||||
| `DLC_DATABASE_SSL_MODE` | `disable` | Tests run without TLS |
|
|
||||||
|
|
||||||
Local setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # Postgres container
|
|
||||||
docker exec dance-lessons-coach-postgres psql -U postgres \
|
|
||||||
-c "CREATE DATABASE dance_lessons_coach_bdd_test;" # one-time
|
|
||||||
```
|
|
||||||
|
|
||||||
In CI: `.gitea/workflows/ci-cd.yaml` provisions a Postgres service container and exports the same vars.
|
|
||||||
|
|
||||||
## Optional env vars
|
|
||||||
|
|
||||||
### `BDD_SCHEMA_ISOLATION` (since [PR #35](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/35) — T12 stage 2/2)
|
|
||||||
|
|
||||||
| Value | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| `true` | Each test PACKAGE (process) gets its own isolated PostgreSQL schema with migrations. Packages run in **parallel** safely. **~2.85x speedup observed locally.** This is the new default in CI. |
|
|
||||||
| (unset / `false`) | Falls back to single shared `public` schema with `CleanupDatabase` (TRUNCATE) between scenarios. Forces sequential package execution (`-p 1`). Slower but simpler. |
|
|
||||||
|
|
||||||
Implementation: `pkg/bdd/testserver/server.go Start()` builds a per-package isolated repo via `user.NewPostgresRepositoryFromDSN` (PR #34). `Stop()` drops the schema + closes the per-package pool.
|
|
||||||
|
|
||||||
ADR-0025 documents the isolation strategy ("Implemented" since PR #35).
|
|
||||||
|
|
||||||
### `FEATURE` (per-package selector)
|
|
||||||
|
|
||||||
When set, `pkg/bdd/testserver/server.go shouldEnableV2()` reads it. Used to scope per-feature behaviour (e.g. enable v2 endpoints only when `FEATURE=greet` AND `GODOG_TAGS` includes `@v2`).
|
|
||||||
|
|
||||||
Without `FEATURE` set, falls back to `bdd` (generic).
|
|
||||||
|
|
||||||
### `GODOG_TAGS` (scenario filter)
|
|
||||||
|
|
||||||
Standard godog env var. The default suite excludes flaky/todo/skip/v2 tags:
|
|
||||||
```
|
|
||||||
GODOG_TAGS="~@flaky && ~@todo && ~@skip && ~@v2"
|
|
||||||
```
|
|
||||||
|
|
||||||
Scoped runs (e.g. `@critical` only): set `GODOG_TAGS="@critical"` and run.
|
|
||||||
|
|
||||||
### `BDD_ENABLE_CLEANUP_LOGS` (debug)
|
|
||||||
|
|
||||||
Set `=true` to log each scenario's CLEANUP / ISOLATION operation. Useful when debugging flakiness.
|
|
||||||
|
|
||||||
## Recommended local commands
|
|
||||||
|
|
||||||
Run all BDD with isolation (parallel, fast):
|
|
||||||
```bash
|
|
||||||
DLC_DATABASE_HOST=localhost DLC_DATABASE_PORT=5432 \
|
|
||||||
DLC_DATABASE_USER=postgres DLC_DATABASE_PASSWORD=postgres \
|
|
||||||
DLC_DATABASE_NAME=dance_lessons_coach_bdd_test DLC_DATABASE_SSL_MODE=disable \
|
|
||||||
BDD_SCHEMA_ISOLATION=true \
|
|
||||||
go test ./features/...
|
|
||||||
```
|
|
||||||
|
|
||||||
Run one feature with v2 enabled:
|
|
||||||
```bash
|
|
||||||
DLC_DATABASE_HOST=... \
|
|
||||||
BDD_SCHEMA_ISOLATION=true FEATURE=greet GODOG_TAGS="@v2" \
|
|
||||||
go test ./features/greet/...
|
|
||||||
```
|
|
||||||
|
|
||||||
Repro CI conditions (sequential, no isolation):
|
|
||||||
```bash
|
|
||||||
DLC_DATABASE_HOST=... \
|
|
||||||
go test ./features/... -p 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cross-references
|
|
||||||
|
|
||||||
- [BDD_GUIDE.md](BDD_GUIDE.md) — authoring scenarios + steps
|
|
||||||
- [ADR-0008](../adr/0008-bdd-testing.md) — choice of Godog
|
|
||||||
- [ADR-0024](../adr/0024-bdd-test-organization-and-isolation.md) — feature directory organization
|
|
||||||
- [ADR-0025](../adr/0025-bdd-scenario-isolation-strategies.md) — isolation strategies (Implemented since PR #35)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# features/info/info.feature
|
|
||||||
@info @critical
|
|
||||||
Feature: Info Endpoint
|
|
||||||
The /api/info endpoint should return composite application information
|
|
||||||
|
|
||||||
@basic @critical
|
|
||||||
Scenario: GET /api/info returns all required fields
|
|
||||||
Given the server is running
|
|
||||||
When I request the info endpoint
|
|
||||||
Then the status code should be 200
|
|
||||||
And the response should be JSON
|
|
||||||
And the response should contain "version"
|
|
||||||
And the response should contain "commit_short"
|
|
||||||
And the response should contain "build_date"
|
|
||||||
And the response should contain "uptime_seconds"
|
|
||||||
And the response should contain "cache_enabled"
|
|
||||||
And the response should contain "healthz_status"
|
|
||||||
And the "healthz_status" field should equal "healthy"
|
|
||||||
|
|
||||||
@version @critical
|
|
||||||
Scenario: version field matches semantic version pattern
|
|
||||||
Given the server is running
|
|
||||||
When I request the info endpoint
|
|
||||||
Then the status code should be 200
|
|
||||||
And the "version" field should match /^\d+\.\d+\.\d+$/
|
|
||||||
|
|
||||||
@cache @skip @bdd-deferred
|
|
||||||
Scenario: /api/info is cached when cache is enabled
|
|
||||||
# Deferred: the BDD testsetup currently runs with cache disabled
|
|
||||||
# (see "Cache service disabled" in test logs). Cache HIT/MISS behavior
|
|
||||||
# is covered by unit tests on the cache service. Reopen this scenario
|
|
||||||
# if/when the BDD harness gains a cache-enabled mode (likely after
|
|
||||||
# ADR-0022 Phase 2).
|
|
||||||
Given the server is running with cache enabled
|
|
||||||
When I request the info endpoint
|
|
||||||
Then the response header "X-Cache" should be "MISS"
|
|
||||||
When I request the info endpoint again
|
|
||||||
Then the response header "X-Cache" should be "HIT"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package info
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/bdd/testsetup"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestInfoBDD(t *testing.T) {
|
|
||||||
config := testsetup.NewFeatureConfig("info", "progress", false)
|
|
||||||
suite := testsetup.CreateTestSuite(t, config, "dance-lessons-coach BDD Tests - Info Feature")
|
|
||||||
|
|
||||||
if suite.Run() != 0 {
|
|
||||||
t.Fatal("non-zero status returned, failed to run info BDD tests")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
|
||||||
stories: ['../components/**/*.stories.@(js|ts|mdx)'],
|
|
||||||
addons: ['@storybook/addon-essentials'],
|
|
||||||
framework: {
|
|
||||||
name: '@storybook/vue3-vite',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
autodocs: 'tag',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { Preview } from '@storybook/vue3'
|
|
||||||
|
|
||||||
const preview: Preview = {
|
|
||||||
parameters: {
|
|
||||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
|
||||||
controls: {
|
|
||||||
matchers: {
|
|
||||||
color: /(background|color)$/i,
|
|
||||||
date: /Date$/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default preview
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout>
|
<NuxtPage />
|
||||||
<NuxtPage />
|
|
||||||
</NuxtLayout>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppFooterView, { type AppInfo } from './AppFooterView.vue'
|
|
||||||
|
|
||||||
// Wrapper: handles data fetching, delegates rendering to AppFooterView.
|
|
||||||
// Separation of concerns (SRP) - same pattern as HealthDashboard / HealthDashboardView.
|
|
||||||
// server: false → fetch client-side only. Avoids SSR fetching through the dev proxy
|
|
||||||
// (which can fail in some local setups), and lets Playwright route mocks apply.
|
|
||||||
const { data, pending, error } = useFetch<AppInfo>('/api/info', { server: false })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppFooterView :data="data" :pending="pending" :error="error" />
|
|
||||||
</template>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { humaniseUptime } from '~/utils/uptime'
|
|
||||||
|
|
||||||
export interface AppInfo {
|
|
||||||
version: string
|
|
||||||
commit_short: string
|
|
||||||
build_date: string
|
|
||||||
uptime_seconds: number
|
|
||||||
cache_enabled: boolean
|
|
||||||
healthz_status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
data: AppInfo | null | undefined
|
|
||||||
pending: boolean
|
|
||||||
error: { message: string } | null | undefined
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<footer data-testid="app-footer">
|
|
||||||
<p v-if="pending" data-testid="app-footer-pending">v?</p>
|
|
||||||
<p v-else-if="error" data-testid="app-footer-error">v? · info unavailable</p>
|
|
||||||
<p v-else-if="data" data-testid="app-footer-info">
|
|
||||||
<span data-testid="app-footer-version">v{{ data.version }}</span>
|
|
||||||
<span> · commit </span>
|
|
||||||
<span data-testid="app-footer-commit">{{ data.commit_short }}</span>
|
|
||||||
<span> · uptime </span>
|
|
||||||
<span data-testid="app-footer-uptime">{{ humaniseUptime(data.uptime_seconds) }}</span>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
footer {
|
|
||||||
border-top: 1px solid #ccc;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #555;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
footer p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
||||||
import HealthDashboard from './HealthDashboard.vue'
|
|
||||||
|
|
||||||
const meta: Meta<typeof HealthDashboard> = {
|
|
||||||
title: 'Components/HealthDashboard',
|
|
||||||
component: HealthDashboard,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Smart wrapper that calls /api/healthz internally and delegates rendering to HealthDashboardView. ' +
|
|
||||||
'For state-by-state previews (Healthy, Loading, Error), see ' +
|
|
||||||
'[HealthDashboardView stories](?path=/docs/components-healthdashboardview--docs).',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
export default meta
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
// Default story - calls real /api/healthz (works in browser if dev proxy + backend are up)
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {},
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import HealthDashboardView, { type HealthInfo } from './HealthDashboardView.vue'
|
interface HealthInfo {
|
||||||
|
status: string
|
||||||
// Wrapper: handles data fetching, delegates rendering to HealthDashboardView.
|
version: string
|
||||||
// Separation of concerns (SRP):
|
uptime_seconds: number
|
||||||
// - HealthDashboard (this) = data layer (useFetch lifecycle)
|
timestamp: string
|
||||||
// - HealthDashboardView = presentation layer (testable in Storybook + e2e)
|
}
|
||||||
//
|
const { data, pending, error } = await useFetch<HealthInfo>('/api/healthz')
|
||||||
// server: false → fetch client-side only. Avoids SSR fetching through the dev
|
|
||||||
// proxy (which can fail in some local setups), and lets Playwright route mocks
|
|
||||||
// apply. Same fix that landed for AppFooter in PR #40.
|
|
||||||
const { data, pending, error } = useFetch<HealthInfo>('/api/healthz', { server: false })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<HealthDashboardView :data="data" :pending="pending" :error="error" />
|
<section data-testid="health-dashboard">
|
||||||
|
<h2>Server Health</h2>
|
||||||
|
<p v-if="pending">Loading...</p>
|
||||||
|
<p v-else-if="error">Error loading health: {{ error.message }}</p>
|
||||||
|
<ul v-else-if="data" data-testid="health-info">
|
||||||
|
<li><strong>Status:</strong> <span data-testid="health-status">{{ data.status }}</span></li>
|
||||||
|
<li><strong>Version:</strong> {{ data.version }}</li>
|
||||||
|
<li><strong>Uptime:</strong> {{ data.uptime_seconds }} seconds</li>
|
||||||
|
<li><strong>Last check:</strong> {{ data.timestamp }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
||||||
import HealthDashboardView from './HealthDashboardView.vue'
|
|
||||||
|
|
||||||
interface ViewArgs {
|
|
||||||
data: {
|
|
||||||
status: string
|
|
||||||
version: string
|
|
||||||
uptime_seconds: number
|
|
||||||
timestamp: string
|
|
||||||
} | null
|
|
||||||
pending: boolean
|
|
||||||
error: { message: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Components/HealthDashboardView',
|
|
||||||
component: HealthDashboardView,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
argTypes: {
|
|
||||||
pending: { control: 'boolean' },
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component:
|
|
||||||
'Pure presentational component for the health dashboard. ' +
|
|
||||||
'Accepts `data`, `pending`, `error` as props so all 3 states can be ' +
|
|
||||||
'previewed in Storybook and asserted in unit tests. The data fetching ' +
|
|
||||||
'wrapper is `HealthDashboard.vue`.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<ViewArgs>
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
export const Healthy: Story = {
|
|
||||||
args: {
|
|
||||||
data: {
|
|
||||||
status: 'healthy',
|
|
||||||
version: '1.4.0',
|
|
||||||
uptime_seconds: 3600,
|
|
||||||
timestamp: '2026-05-03T17:30:00.000Z',
|
|
||||||
},
|
|
||||||
pending: false,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Loading: Story = {
|
|
||||||
args: {
|
|
||||||
data: null,
|
|
||||||
pending: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ErrorState: Story = {
|
|
||||||
args: {
|
|
||||||
data: null,
|
|
||||||
pending: false,
|
|
||||||
error: { message: '[GET] "/api/healthz": 502 Bad Gateway (simulated)' },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HealthyHighUptime: Story = {
|
|
||||||
args: {
|
|
||||||
data: {
|
|
||||||
status: 'healthy',
|
|
||||||
version: '1.5.0-rc1',
|
|
||||||
uptime_seconds: 86400 * 7,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
pending: false,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
export interface HealthInfo {
|
|
||||||
status: string
|
|
||||||
version: string
|
|
||||||
uptime_seconds: number
|
|
||||||
timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
data: HealthInfo | null | undefined
|
|
||||||
pending: boolean
|
|
||||||
error: { message: string } | null | undefined
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section data-testid="health-dashboard">
|
|
||||||
<h2>Server Health</h2>
|
|
||||||
<p v-if="pending" data-testid="health-loading">Loading...</p>
|
|
||||||
<p v-else-if="error" data-testid="health-error">
|
|
||||||
Error loading health: {{ error.message }}
|
|
||||||
</p>
|
|
||||||
<ul v-else-if="data" data-testid="health-info">
|
|
||||||
<li><strong>Status:</strong> <span data-testid="health-status">{{ data.status }}</span></li>
|
|
||||||
<li><strong>Version:</strong> {{ data.version }}</li>
|
|
||||||
<li><strong>Uptime:</strong> {{ data.uptime_seconds }} seconds</li>
|
|
||||||
<li><strong>Last check:</strong> {{ data.timestamp }}</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Frontend Docs
|
|
||||||
|
|
||||||
- [E2E Test Reports](./e2e/README.md) - auto-generated by `npm run docs:gen`
|
|
||||||
- Storybook (run locally: `npm run storybook` ; build: `npm run build-storybook` then open `storybook-static/index.html`)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# E2E Test Reports
|
|
||||||
|
|
||||||
[<- Up to docs](../README.md)
|
|
||||||
|
|
||||||
| Test | Status | Duration |
|
|
||||||
|------|--------|----------|
|
|
||||||
| [home page loads and shows server health info](./home-page-loads-and-shows-server-health-info.md) | PASSED | 168ms |
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# home page loads and shows server health info
|
|
||||||
|
|
||||||
[<- Back to index](./README.md) | [Top](../README.md)
|
|
||||||
|
|
||||||
**File**: `tests/e2e/health.spec.ts`
|
|
||||||
**Status**: PASSED
|
|
||||||
**Duration**: 168ms
|
|
||||||
|
|
||||||
## Screenshot
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Test Details
|
|
||||||
|
|
||||||
- Start Time: 2026-05-03T14:38:42.958Z
|
|
||||||
- Spec File: health.spec.ts
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="layout-root">
|
|
||||||
<slot />
|
|
||||||
<AppFooter />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.layout-root {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.layout-root > :first-child {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
2348
frontend/package-lock.json
generated
2348
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,20 +6,12 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare"
|
||||||
"storybook": "storybook dev -p 6006",
|
|
||||||
"build-storybook": "storybook build",
|
|
||||||
"docs:gen": "playwright test && node scripts/generate-test-docs.mjs",
|
|
||||||
"docs:full": "npm run build-storybook && npm run docs:gen"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@storybook/addon-essentials": "^8.0.0",
|
|
||||||
"@storybook/vue3": "^8.0.0",
|
|
||||||
"@storybook/vue3-vite": "^8.0.0",
|
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"nuxt": "^3.13.0",
|
"nuxt": "^3.13.0",
|
||||||
"storybook": "^8.0.0",
|
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "npm@11.5.2"
|
"packageManager": "npm@11.5.2"
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { defineConfig } from '@playwright/test'
|
import { defineConfig } from '@playwright/test'
|
||||||
import path from 'path'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
testDir: './tests/e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
reporter: [
|
|
||||||
['list'],
|
|
||||||
['json', { outputFile: path.join(process.cwd(), 'test-results', 'results.json') }],
|
|
||||||
],
|
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: 'http://localhost:3000',
|
||||||
screenshot: 'on',
|
|
||||||
video: 'off',
|
|
||||||
},
|
},
|
||||||
outputDir: 'test-results/output',
|
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
command: 'npm run dev',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import fs from 'node:fs/promises'
|
|
||||||
import path from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
||||||
const frontendDir = path.resolve(__dirname, '..')
|
|
||||||
|
|
||||||
const resultsPath = path.join(frontendDir, 'test-results', 'results.json')
|
|
||||||
const docsDir = path.join(frontendDir, 'docs', 'e2e')
|
|
||||||
const screenshotsDir = path.join(frontendDir, 'tests', 'e2e', 'screenshots')
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Read results
|
|
||||||
const resultsText = await fs.readFile(resultsPath, 'utf8')
|
|
||||||
const results = JSON.parse(resultsText)
|
|
||||||
|
|
||||||
// Create output directories
|
|
||||||
await fs.mkdir(docsDir, { recursive: true })
|
|
||||||
|
|
||||||
// Extract tests from suites
|
|
||||||
const testDocs = []
|
|
||||||
for (const suite of results.suites || []) {
|
|
||||||
for (const spec of suite.specs || []) {
|
|
||||||
for (const test of spec.tests || []) {
|
|
||||||
for (const result of test.results || []) {
|
|
||||||
const testInfo = {
|
|
||||||
title: spec.title,
|
|
||||||
specFile: spec.file || suite.file,
|
|
||||||
status: result.status,
|
|
||||||
duration: result.duration,
|
|
||||||
startTime: result.startTime,
|
|
||||||
attachments: result.attachments || [],
|
|
||||||
}
|
|
||||||
testDocs.push(testInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate individual test markdown files
|
|
||||||
for (const test of testDocs) {
|
|
||||||
const slug = slugify(test.title)
|
|
||||||
const mdPath = path.join(docsDir, `${slug}.md`)
|
|
||||||
|
|
||||||
// Use slug-based screenshot name (matches explicit screenshot in test)
|
|
||||||
let screenshotPath = `${slug}.png`
|
|
||||||
|
|
||||||
// Also try to find screenshot attachment and use its basename
|
|
||||||
if (test.attachments && test.attachments.length > 0) {
|
|
||||||
for (const attachment of test.attachments) {
|
|
||||||
if (attachment.contentType === 'image/png') {
|
|
||||||
const basename = path.basename(attachment.path)
|
|
||||||
// Prefer explicit screenshot name if it matches our pattern
|
|
||||||
if (basename !== 'test-finished-1.png' && basename !== 'test-finished-2.png') {
|
|
||||||
screenshotPath = basename
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const absoluteScreenshotPath = path.join(screenshotsDir, screenshotPath)
|
|
||||||
const relativeScreenshotPath = path.relative(docsDir, absoluteScreenshotPath)
|
|
||||||
|
|
||||||
const mdContent = `# ${test.title}
|
|
||||||
|
|
||||||
[<- Back to index](./README.md) | [Top](../README.md)
|
|
||||||
|
|
||||||
**File**: \`tests/e2e/${test.specFile}\`
|
|
||||||
**Status**: ${test.status.toUpperCase()}
|
|
||||||
**Duration**: ${test.duration}ms
|
|
||||||
|
|
||||||
## Screenshot
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Test Details
|
|
||||||
|
|
||||||
- Start Time: ${test.startTime || 'N/A'}
|
|
||||||
- Spec File: ${test.specFile}
|
|
||||||
`
|
|
||||||
|
|
||||||
await fs.writeFile(mdPath, mdContent)
|
|
||||||
console.log(`Generated: ${path.relative(frontendDir, mdPath)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate index README
|
|
||||||
const indexContent = `# E2E Test Reports
|
|
||||||
|
|
||||||
[<- Up to docs](../README.md)
|
|
||||||
|
|
||||||
| Test | Status | Duration |
|
|
||||||
|------|--------|----------|
|
|
||||||
${testDocs.map(t => `| [${escapeMd(t.title)}](./${slugify(t.title)}.md) | ${t.status.toUpperCase()} | ${t.duration}ms |`).join('\n')}
|
|
||||||
`
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(docsDir, 'README.md'), indexContent)
|
|
||||||
console.log(`Generated: ${path.relative(frontendDir, path.join(docsDir, 'README.md'))}`)
|
|
||||||
|
|
||||||
console.log(`\nGenerated ${testDocs.length} test docs`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(str) {
|
|
||||||
return str
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s-]/g, '')
|
|
||||||
.replace(/[\s_]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeMd(str) {
|
|
||||||
return str.replace(/[|\\\[\]\{\}]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('Error:', err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
6
frontend/shims-vue.d.ts
vendored
6
frontend/shims-vue.d.ts
vendored
@@ -1,6 +0,0 @@
|
|||||||
declare module '*.vue' {
|
|
||||||
import type { DefineComponent } from 'vue'
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const component: DefineComponent<any, any, any>
|
|
||||||
export default component
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
// Both specs mock /api/info so they decouple from the dev-proxy plumbing.
|
|
||||||
// The integration with the real backend is covered by the BDD scenario in
|
|
||||||
// features/info/info.feature (server-side, no frontend proxy in the loop).
|
|
||||||
|
|
||||||
test('home page footer shows version, commit and uptime', async ({ page }) => {
|
|
||||||
await page.route('**/api/info', (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
version: '1.4.0',
|
|
||||||
commit_short: '4a3f1bb',
|
|
||||||
build_date: '2026-05-05T00:00:00Z',
|
|
||||||
uptime_seconds: 8042,
|
|
||||||
cache_enabled: true,
|
|
||||||
healthz_status: 'healthy',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Footer is mounted globally via layouts/default.vue
|
|
||||||
await expect(page.getByTestId('app-footer')).toBeVisible()
|
|
||||||
|
|
||||||
// The PR #32 lesson: assert content, not just visibility.
|
|
||||||
// Without the regex check the test would PASS even if the footer rendered the
|
|
||||||
// pending placeholder ("v?") indefinitely.
|
|
||||||
await expect(page.getByTestId('app-footer-info')).toBeVisible()
|
|
||||||
const versionLocator = page.getByTestId('app-footer-version')
|
|
||||||
await expect(versionLocator).toBeVisible()
|
|
||||||
await expect(versionLocator).toHaveText(/^v\d+\.\d+\.\d+$/)
|
|
||||||
|
|
||||||
// Commit and uptime should be present and non-empty.
|
|
||||||
await expect(page.getByTestId('app-footer-commit')).not.toBeEmpty()
|
|
||||||
await expect(page.getByTestId('app-footer-uptime')).not.toBeEmpty()
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: 'tests/e2e/screenshots/app-footer-shows-version-commit-uptime.png',
|
|
||||||
fullPage: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Regression spec: documents the expected error UX so we don't ship a silent failure.
|
|
||||||
// Routes /api/info to a 502 mock so the test is reproducible regardless of backend.
|
|
||||||
test('home page footer surfaces info endpoint errors gracefully', async ({ page }) => {
|
|
||||||
await page.route('**/api/info', (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 502,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ error: 'simulated_backend_down' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.goto('/')
|
|
||||||
|
|
||||||
// Footer must NOT crash the page
|
|
||||||
await expect(page.getByTestId('app-footer')).toBeVisible()
|
|
||||||
await expect(page.getByTestId('app-footer-error')).toBeVisible()
|
|
||||||
// The error placeholder should NOT contain a real version pattern
|
|
||||||
await expect(page.getByTestId('app-footer-info')).not.toBeVisible()
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: 'tests/e2e/screenshots/app-footer-surfaces-info-endpoint-errors-gracefully.png',
|
|
||||||
fullPage: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,55 +1,8 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
// Both specs mock /api/healthz so they decouple from the dev-proxy plumbing.
|
test('home page loads and shows server health info', async ({ page }) => {
|
||||||
// The integration with the real backend is covered by the BDD scenario in
|
|
||||||
// features/health/health.feature (server-side, no frontend proxy in the loop).
|
|
||||||
// Same approach as tests/e2e/app-footer.spec.ts (PR #40) - applied here to
|
|
||||||
// close the debt left by that PR's out-of-scope follow-up note.
|
|
||||||
|
|
||||||
test('home page loads and shows healthy server state', async ({ page }) => {
|
|
||||||
await page.route('**/api/healthz', (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({
|
|
||||||
status: 'healthy',
|
|
||||||
version: '1.4.0',
|
|
||||||
uptime_seconds: 8042,
|
|
||||||
timestamp: '2026-05-05T08:00:00Z',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
||||||
const heading = page.getByRole('heading', { name: /dance-lessons-coach/i })
|
const heading = page.getByRole('heading', { name: /dance-lessons-coach/i })
|
||||||
await expect(heading).toBeVisible()
|
await expect(heading).toBeVisible()
|
||||||
|
|
||||||
// Assert the dashboard is in HEALTHY state, not an error state.
|
|
||||||
// The dashboard renders an "Error loading health: ..." paragraph when /api/healthz
|
|
||||||
// is unreachable (Go backend not running, proxy misconfigured, endpoint removed,
|
|
||||||
// etc.). Without these assertions the test would falsely PASS even when the
|
|
||||||
// dashboard shows the error UI - regression observed 2026-05-03 (Go backend
|
|
||||||
// not running locally → page renders the error, Playwright PASSES).
|
|
||||||
await expect(page.getByTestId('health-info')).toBeVisible()
|
|
||||||
await expect(page.getByTestId('health-status')).toHaveText('healthy')
|
|
||||||
await expect(page.getByText(/Error loading health/i)).not.toBeVisible()
|
|
||||||
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/home-page-loads-and-shows-server-health-info.png', fullPage: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
// Regression spec: documents the expected error UX so we don't ship a silent failure.
|
|
||||||
// Routes /api/healthz to a 502 mock so the test is reproducible regardless of backend.
|
|
||||||
test('home page surfaces health endpoint errors visibly', async ({ page }) => {
|
|
||||||
await page.route('**/api/healthz', (route) => {
|
|
||||||
route.fulfill({
|
|
||||||
status: 502,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify({ error: 'simulated_backend_down' }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await page.goto('/')
|
|
||||||
await expect(page.getByTestId('health-dashboard')).toBeVisible()
|
|
||||||
await expect(page.getByText(/Error loading health/i)).toBeVisible()
|
|
||||||
await expect(page.getByTestId('health-info')).not.toBeVisible()
|
|
||||||
await page.screenshot({ path: 'tests/e2e/screenshots/home-page-surfaces-health-endpoint-errors-visibly.png', fullPage: true })
|
|
||||||
})
|
})
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,16 +0,0 @@
|
|||||||
// Convert a duration in seconds to a humanised string like "2h 13m" or "45m 12s".
|
|
||||||
// Returns "?" for non-finite or negative input so the UI never renders NaN/empty.
|
|
||||||
export function humaniseUptime(seconds: number | null | undefined): string {
|
|
||||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '?'
|
|
||||||
|
|
||||||
const s = Math.floor(seconds)
|
|
||||||
const days = Math.floor(s / 86400)
|
|
||||||
const hours = Math.floor((s % 86400) / 3600)
|
|
||||||
const minutes = Math.floor((s % 3600) / 60)
|
|
||||||
const secs = s % 60
|
|
||||||
|
|
||||||
if (days > 0) return `${days}d ${hours}h`
|
|
||||||
if (hours > 0) return `${hours}h ${minutes}m`
|
|
||||||
if (minutes > 0) return `${minutes}m ${secs}s`
|
|
||||||
return `${secs}s`
|
|
||||||
}
|
|
||||||
2
go.mod
2
go.mod
@@ -4,7 +4,6 @@ go 1.26.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/cucumber/godog v0.15.1
|
github.com/cucumber/godog v0.15.1
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-playground/locales v0.14.1
|
github.com/go-playground/locales v0.14.1
|
||||||
github.com/go-playground/universal-translator v0.18.1
|
github.com/go-playground/universal-translator v0.18.1
|
||||||
@@ -38,6 +37,7 @@ require (
|
|||||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package steps
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/bdd/testserver"
|
"dance-lessons-coach/pkg/bdd/testserver"
|
||||||
@@ -100,69 +99,3 @@ func (s *CommonSteps) theFieldShouldEqual(field, expectedValue string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regex field matching
|
|
||||||
func (s *CommonSteps) theFieldShouldMatch(field, pattern string) error {
|
|
||||||
body := string(s.client.GetLastBody())
|
|
||||||
// Extract the value of the field from JSON
|
|
||||||
// Look for "field":"value" and extract value
|
|
||||||
fieldPattern := `"` + field + `":"([^"]*)"`
|
|
||||||
re := regexp.MustCompile(fieldPattern)
|
|
||||||
matches := re.FindStringSubmatch(body)
|
|
||||||
if matches == nil {
|
|
||||||
// Try without quotes (for numbers)
|
|
||||||
fieldPatternNum := `"` + field + `":(\d+\.?\d*)`
|
|
||||||
reNum := regexp.MustCompile(fieldPatternNum)
|
|
||||||
matches = reNum.FindStringSubmatch(body)
|
|
||||||
if matches == nil {
|
|
||||||
return fmt.Errorf("field %q not found in response: %s", field, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// matches[1] contains the value
|
|
||||||
value := matches[1]
|
|
||||||
|
|
||||||
// Compile and match the pattern
|
|
||||||
regex, err := regexp.Compile(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid regex pattern %q: %v", pattern, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !regex.MatchString(value) {
|
|
||||||
return fmt.Errorf("field %q value %q does not match pattern %q", field, value, pattern)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response is JSON check
|
|
||||||
func (s *CommonSteps) theResponseShouldBeJSON() error {
|
|
||||||
body := string(s.client.GetLastBody())
|
|
||||||
// Simple check for JSON structure
|
|
||||||
body = strings.TrimSpace(body)
|
|
||||||
if !strings.HasPrefix(body, "{") && !strings.HasPrefix(body, "[") {
|
|
||||||
return fmt.Errorf("response is not JSON: %s", body)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response contains field (simple string containment in body)
|
|
||||||
func (s *CommonSteps) theResponseShouldContain(field string) error {
|
|
||||||
body := string(s.client.GetLastBody())
|
|
||||||
if !strings.Contains(body, `"`+field+`"`) {
|
|
||||||
return fmt.Errorf("response does not contain field %q: %s", field, body)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response header validation
|
|
||||||
func (s *CommonSteps) theResponseHeader(header, expectedValue string) error {
|
|
||||||
resp := s.client.GetLastResponse()
|
|
||||||
if resp == nil {
|
|
||||||
return fmt.Errorf("no response captured for header check")
|
|
||||||
}
|
|
||||||
headerValue := resp.Header.Get(header)
|
|
||||||
if headerValue != expectedValue {
|
|
||||||
return fmt.Errorf("header %q expected %q, got %q", header, expectedValue, headerValue)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,19 +28,7 @@ func (s *HealthSteps) iRequestTheHealthzEndpoint() error {
|
|||||||
return s.client.Request("GET", "/api/healthz", nil)
|
return s.client.Request("GET", "/api/healthz", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HealthSteps) iRequestTheInfoEndpoint() error {
|
|
||||||
return s.client.Request("GET", "/api/info", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *HealthSteps) iRequestTheInfoEndpointAgain() error {
|
|
||||||
return s.client.Request("GET", "/api/info", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *HealthSteps) theServerIsRunning() error {
|
func (s *HealthSteps) theServerIsRunning() error {
|
||||||
// Actually verify the server is running by checking the readiness endpoint
|
// Actually verify the server is running by checking the readiness endpoint
|
||||||
return s.client.Request("GET", "/api/ready", nil)
|
return s.client.Request("GET", "/api/ready", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HealthSteps) theServerIsRunningWithCacheEnabled() error {
|
|
||||||
return s.client.Request("GET", "/api/ready", nil)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,9 +89,6 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
|
|||||||
// Health steps
|
// Health steps
|
||||||
ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint)
|
ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint)
|
||||||
ctx.Step(`^I request the healthz endpoint$`, sc.healthSteps.iRequestTheHealthzEndpoint)
|
ctx.Step(`^I request the healthz endpoint$`, sc.healthSteps.iRequestTheHealthzEndpoint)
|
||||||
ctx.Step(`^I request the info endpoint$`, sc.healthSteps.iRequestTheInfoEndpoint)
|
|
||||||
ctx.Step(`^I request the info endpoint again$`, sc.healthSteps.iRequestTheInfoEndpointAgain)
|
|
||||||
ctx.Step(`^the server is running with cache enabled$`, sc.healthSteps.theServerIsRunningWithCacheEnabled)
|
|
||||||
ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning)
|
ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning)
|
||||||
|
|
||||||
// Auth steps
|
// Auth steps
|
||||||
@@ -317,8 +314,4 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
|
|||||||
ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe)
|
ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe)
|
||||||
ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields)
|
ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields)
|
||||||
ctx.Step(`^the "([^"]*)" field should equal "([^"]*)"$`, sc.commonSteps.theFieldShouldEqual)
|
ctx.Step(`^the "([^"]*)" field should equal "([^"]*)"$`, sc.commonSteps.theFieldShouldEqual)
|
||||||
ctx.Step(`^the "([^"]*)" field should match /([^/]+)/$`, sc.commonSteps.theFieldShouldMatch)
|
|
||||||
ctx.Step(`^the response should be JSON$`, sc.commonSteps.theResponseShouldBeJSON)
|
|
||||||
ctx.Step(`^the response should contain "([^"]*)"$`, sc.commonSteps.theResponseShouldContain)
|
|
||||||
ctx.Step(`^the response header "([^"]*)" should be "([^"]*)"$`, sc.commonSteps.theResponseHeader)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,15 +115,6 @@ func InitializeTestSuite(ctx *godog.TestSuiteContext) {
|
|||||||
testserver.TraceStateJWTSecretOperation(feature, scenarioKey, "RESET", "ok")
|
testserver.TraceStateJWTSecretOperation(feature, scenarioKey, "RESET", "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush cache after every scenario to prevent cache pollution
|
|
||||||
if flushErr := sharedServer.FlushCache(); flushErr != nil {
|
|
||||||
if isCleanupLoggingEnabled() {
|
|
||||||
log.Warn().Err(flushErr).Msg("CLEANUP: Failed to flush cache after scenario")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
testserver.TraceStateCacheOperation(feature, scenarioKey, "FLUSH", "ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean database after every scenario (only if schema isolation is disabled)
|
// Clean database after every scenario (only if schema isolation is disabled)
|
||||||
if !isSchemaIsolationEnabled() {
|
if !isSchemaIsolationEnabled() {
|
||||||
if cleanupErr := sharedServer.CleanupDatabase(); cleanupErr != nil {
|
if cleanupErr := sharedServer.CleanupDatabase(); cleanupErr != nil {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/cache"
|
|
||||||
"dance-lessons-coach/pkg/config"
|
"dance-lessons-coach/pkg/config"
|
||||||
"dance-lessons-coach/pkg/server"
|
"dance-lessons-coach/pkg/server"
|
||||||
"dance-lessons-coach/pkg/user"
|
"dance-lessons-coach/pkg/user"
|
||||||
@@ -48,13 +47,10 @@ type Server struct {
|
|||||||
port int
|
port int
|
||||||
baseURL string
|
baseURL string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
authService user.AuthService // Reference to auth service for cleanup
|
authService user.AuthService // Reference to auth service for cleanup
|
||||||
cacheService cache.Service // Reference to cache service for cleanup
|
schemaMutex sync.Mutex // Protects schema operations
|
||||||
isolatedRepo *user.PostgresRepository // Per-package isolated repo (BDD_SCHEMA_ISOLATION=true)
|
currentSchema string // Current schema being used
|
||||||
isolatedSchemaName string // Per-package schema name to drop on Stop()
|
originalSearchPath string // Original search_path to restore
|
||||||
schemaMutex sync.Mutex // Protects schema operations
|
|
||||||
currentSchema string // Current schema being used
|
|
||||||
originalSearchPath string // Original search_path to restore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDatabaseHost returns the database host from environment variable or defaults to localhost
|
// getDatabaseHost returns the database host from environment variable or defaults to localhost
|
||||||
@@ -150,62 +146,13 @@ func (s *Server) Start() error {
|
|||||||
// This is the ONLY place where we check env vars for v2 configuration
|
// This is the ONLY place where we check env vars for v2 configuration
|
||||||
v2Enabled := s.shouldEnableV2()
|
v2Enabled := s.shouldEnableV2()
|
||||||
|
|
||||||
// Create real server instance from pkg/server.
|
// Create real server instance from pkg/server
|
||||||
// When BDD_SCHEMA_ISOLATION=true, each test package (process) gets its own
|
|
||||||
// isolated PostgreSQL schema with its own connection pool + migrations.
|
|
||||||
// This makes `go test ./features/...` parallel-safe because each feature
|
|
||||||
// package runs in its own process and gets its own schema.
|
|
||||||
cfg := createTestConfig(s.port, v2Enabled)
|
cfg := createTestConfig(s.port, v2Enabled)
|
||||||
var realServer *server.Server
|
realServer := server.NewServer(cfg, context.Background())
|
||||||
if isSchemaIsolationEnabled() {
|
|
||||||
feature := os.Getenv("FEATURE")
|
|
||||||
if feature == "" {
|
|
||||||
feature = "bdd"
|
|
||||||
}
|
|
||||||
schemaName := generateSchemaName(feature, "package_root")
|
|
||||||
log.Info().Str("schema", schemaName).Str("feature", feature).Msg("ISOLATION: Building per-package isolated repo")
|
|
||||||
|
|
||||||
// Connect a default repo briefly just to CREATE SCHEMA (uses cfg from env vars)
|
|
||||||
bootstrapRepo, err := user.NewPostgresRepository(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ISOLATION bootstrap repo failed: %w", err)
|
|
||||||
}
|
|
||||||
// Drop + recreate to ensure clean slate per process
|
|
||||||
_ = bootstrapRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName))
|
|
||||||
if err := bootstrapRepo.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName)); err != nil {
|
|
||||||
bootstrapRepo.Close()
|
|
||||||
return fmt.Errorf("ISOLATION CREATE SCHEMA failed: %w", err)
|
|
||||||
}
|
|
||||||
bootstrapRepo.Close()
|
|
||||||
|
|
||||||
// Build the per-package isolated repo (runs migrations in the new schema)
|
|
||||||
dsn := user.BuildSchemaIsolatedDSN(cfg, schemaName)
|
|
||||||
isolatedRepo, err := user.NewPostgresRepositoryFromDSN(cfg, dsn)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ISOLATION isolated repo failed: %w", err)
|
|
||||||
}
|
|
||||||
s.isolatedRepo = isolatedRepo
|
|
||||||
s.isolatedSchemaName = schemaName
|
|
||||||
|
|
||||||
// Build user service backed by the isolated repo
|
|
||||||
jwtConfig := user.JWTConfig{
|
|
||||||
Secret: cfg.GetJWTSecret(),
|
|
||||||
ExpirationTime: time.Hour * 24,
|
|
||||||
Issuer: "dance-lessons-coach",
|
|
||||||
}
|
|
||||||
isolatedUserService := user.NewUserService(isolatedRepo, jwtConfig, cfg.GetAdminMasterPassword())
|
|
||||||
|
|
||||||
realServer = server.NewServerWithUserRepo(cfg, context.Background(), isolatedRepo, isolatedUserService)
|
|
||||||
} else {
|
|
||||||
realServer = server.NewServer(cfg, context.Background())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store auth service for cleanup
|
// Store auth service for cleanup
|
||||||
s.authService = realServer.GetAuthService()
|
s.authService = realServer.GetAuthService()
|
||||||
|
|
||||||
// Store cache service for cleanup
|
|
||||||
s.cacheService = realServer.GetCacheService()
|
|
||||||
|
|
||||||
// Initialize database connection for cleanup
|
// Initialize database connection for cleanup
|
||||||
if err := s.initDBConnection(); err != nil {
|
if err := s.initDBConnection(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize database connection: %w", err)
|
return fmt.Errorf("failed to initialize database connection: %w", err)
|
||||||
@@ -462,23 +409,6 @@ func (s *Server) ResetJWTSecrets() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlushCache clears all cached data to prevent cache pollution between scenarios
|
|
||||||
// This prevents cached responses from affecting subsequent test scenarios
|
|
||||||
func (s *Server) FlushCache() error {
|
|
||||||
if s.cacheService == nil {
|
|
||||||
if isCleanupLoggingEnabled() {
|
|
||||||
log.Info().Msg("CLEANUP: No cache service available, skipping cache flush")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s.cacheService.Flush()
|
|
||||||
if isCleanupLoggingEnabled() {
|
|
||||||
log.Info().Msg("CLEANUP: Cache flushed successfully")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupDatabase deletes all test data from all tables
|
// CleanupDatabase deletes all test data from all tables
|
||||||
// This uses raw SQL to avoid dependency on repositories and handles foreign keys properly
|
// This uses raw SQL to avoid dependency on repositories and handles foreign keys properly
|
||||||
// Uses SET CONSTRAINTS ALL DEFERRED to temporarily disable foreign key checks
|
// Uses SET CONSTRAINTS ALL DEFERRED to temporarily disable foreign key checks
|
||||||
@@ -625,7 +555,7 @@ func (s *Server) SetupScenarioSchema(feature, scenario string) error {
|
|||||||
return fmt.Errorf("failed to create schema %s: %w", schemaName, err)
|
return fmt.Errorf("failed to create schema %s: %w", schemaName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set search path to use the new schema (testserver's own connection)
|
// Set search path to use the new schema
|
||||||
searchPathSQL := fmt.Sprintf("SET search_path = %s, %s", schemaName, s.originalSearchPath)
|
searchPathSQL := fmt.Sprintf("SET search_path = %s, %s", schemaName, s.originalSearchPath)
|
||||||
if _, err := s.db.Exec(searchPathSQL); err != nil {
|
if _, err := s.db.Exec(searchPathSQL); err != nil {
|
||||||
return fmt.Errorf("failed to set search_path: %w", err)
|
return fmt.Errorf("failed to set search_path: %w", err)
|
||||||
@@ -687,21 +617,6 @@ func (s *Server) getCurrentSearchPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Stop() error {
|
func (s *Server) Stop() error {
|
||||||
// Cleanup the per-package isolated schema + close its pool, if any.
|
|
||||||
// (BDD_SCHEMA_ISOLATION=true path - see Start().)
|
|
||||||
if s.isolatedRepo != nil {
|
|
||||||
if s.isolatedSchemaName != "" {
|
|
||||||
if err := s.isolatedRepo.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", s.isolatedSchemaName)); err != nil {
|
|
||||||
log.Warn().Err(err).Str("schema", s.isolatedSchemaName).Msg("ISOLATION: failed to drop schema on Stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.isolatedRepo.Close(); err != nil {
|
|
||||||
log.Warn().Err(err).Msg("ISOLATION: failed to close isolated repo")
|
|
||||||
}
|
|
||||||
s.isolatedRepo = nil
|
|
||||||
s.isolatedSchemaName = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -31,11 +31,6 @@ func TraceStateJWTSecretOperation(feature, scenario, operation, details string)
|
|||||||
writeTraceLine(feature, scenario, "JWT_"+operation, details)
|
writeTraceLine(feature, scenario, "JWT_"+operation, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TraceStateCacheOperation logs a cache operation
|
|
||||||
func TraceStateCacheOperation(feature, scenario, operation, details string) {
|
|
||||||
writeTraceLine(feature, scenario, "CACHE_"+operation, details)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TraceStateSchemaIsolation logs a schema isolation operation
|
// TraceStateSchemaIsolation logs a schema isolation operation
|
||||||
func TraceStateSchemaIsolation(feature, scenario, operation, details string) {
|
func TraceStateSchemaIsolation(feature, scenario, operation, details string) {
|
||||||
writeTraceLine(feature, scenario, "SCHEMA_"+operation, details)
|
writeTraceLine(feature, scenario, "SCHEMA_"+operation, details)
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -16,13 +13,6 @@ import (
|
|||||||
"dance-lessons-coach/pkg/version"
|
"dance-lessons-coach/pkg/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SamplerReconfigureFunc is the signature for callbacks invoked when
|
|
||||||
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
|
|
||||||
// The callback receives the new sampler type and ratio values.
|
|
||||||
// It must be safe to call concurrently — implementations should use their
|
|
||||||
// own synchronisation if needed. Returns an error if the reconfigure fails.
|
|
||||||
type SamplerReconfigureFunc func(ctx context.Context, samplerType string, samplerRatio float64) error
|
|
||||||
|
|
||||||
// NewZerologWriter creates a zerolog writer based on configuration
|
// NewZerologWriter creates a zerolog writer based on configuration
|
||||||
func NewZerologWriter() *os.File {
|
func NewZerologWriter() *os.File {
|
||||||
return os.Stderr
|
return os.Stderr
|
||||||
@@ -39,29 +29,6 @@ type Config struct {
|
|||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
|
|
||||||
// viper is the underlying configuration source. Kept (unexported,
|
|
||||||
// mapstructure:"-") so hot-reload can re-unmarshal on file changes —
|
|
||||||
// see WatchAndApply (ADR-0023 selective hot-reload).
|
|
||||||
viper *viper.Viper `mapstructure:"-"`
|
|
||||||
|
|
||||||
// reloadMu serialises Unmarshal during hot-reload so a partial mutation
|
|
||||||
// can't be observed mid-flight by getter calls.
|
|
||||||
reloadMu sync.RWMutex `mapstructure:"-"`
|
|
||||||
|
|
||||||
// samplerReconfigureCallback is invoked when telemetry.sampler.type or
|
|
||||||
// telemetry.sampler.ratio change. nil means no callback registered.
|
|
||||||
samplerReconfigureCallback SamplerReconfigureFunc `mapstructure:"-"`
|
|
||||||
|
|
||||||
// prevSamplerType and prevSamplerRatio track the last-seen sampler values
|
|
||||||
// to detect changes during hot-reload (ADR-0023 Phase 3).
|
|
||||||
prevSamplerType string `mapstructure:"-"`
|
|
||||||
prevSamplerRatio float64 `mapstructure:"-"`
|
|
||||||
|
|
||||||
// watcherStopped indicates that the config watcher has been stopped via
|
|
||||||
// the context being cancelled. This prevents the OnConfigChange handler
|
|
||||||
// from processing events after cleanup.
|
|
||||||
watcherStopped bool `mapstructure:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds server-related configuration
|
// ServerConfig holds server-related configuration
|
||||||
@@ -332,14 +299,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("config unmarshal error: %w", err)
|
return nil, fmt.Errorf("config unmarshal error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the viper instance for hot-reload (ADR-0023).
|
|
||||||
config.viper = v
|
|
||||||
|
|
||||||
// Initialize previous sampler values for hot-reload change detection
|
|
||||||
// (ADR-0023 Phase 3).
|
|
||||||
config.prevSamplerType = config.Telemetry.Sampler.Type
|
|
||||||
config.prevSamplerRatio = config.Telemetry.Sampler.Ratio
|
|
||||||
|
|
||||||
// Setup logging based on configuration (level, output file, time format).
|
// Setup logging based on configuration (level, output file, time format).
|
||||||
// The JSON/console format was already applied at the top of LoadConfig via
|
// The JSON/console format was already applied at the top of LoadConfig via
|
||||||
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
|
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
|
||||||
@@ -404,19 +363,6 @@ func (c *Config) GetSamplerRatio() float64 {
|
|||||||
return c.Telemetry.Sampler.Ratio
|
return c.Telemetry.Sampler.Ratio
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSamplerReconfigureCallback registers a callback that is invoked when
|
|
||||||
// telemetry.sampler.type or telemetry.sampler.ratio change via hot-reload.
|
|
||||||
// The callback receives the new sampler type and ratio values.
|
|
||||||
// Pass nil to unregister the callback.
|
|
||||||
func (c *Config) SetSamplerReconfigureCallback(callback SamplerReconfigureFunc) {
|
|
||||||
c.reloadMu.Lock()
|
|
||||||
defer c.reloadMu.Unlock()
|
|
||||||
c.samplerReconfigureCallback = callback
|
|
||||||
// Initialize previous values so we can detect changes on first hot-reload
|
|
||||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
|
||||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetV2Enabled returns whether v2 API is enabled
|
// GetV2Enabled returns whether v2 API is enabled
|
||||||
func (c *Config) GetV2Enabled() bool {
|
func (c *Config) GetV2Enabled() bool {
|
||||||
return c.API.V2Enabled
|
return c.API.V2Enabled
|
||||||
@@ -644,100 +590,3 @@ func (c *Config) setupLogOutput() {
|
|||||||
log.Logger = log.Output(file)
|
log.Logger = log.Output(file)
|
||||||
log.Trace().Str("output", output).Msg("Logging to file")
|
log.Trace().Str("output", output).Msg("Logging to file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatchAndApply starts watching the config file for changes and applies the
|
|
||||||
// hot-reloadable subset on every change (ADR-0023 selective hot-reload).
|
|
||||||
//
|
|
||||||
// Phases shipped:
|
|
||||||
// - Phase 1: logging.level — re-applied via SetupLogging on every change.
|
|
||||||
// - Phase 2: auth.jwt.ttl — picked up automatically because the userService
|
|
||||||
// reads it via JWTConfig.GetTTL (a method value capturing this *Config).
|
|
||||||
// The reloaded TTL is used on the NEXT token generation; tokens issued
|
|
||||||
// before the change keep their original expiry.
|
|
||||||
// - Phase 3: telemetry.sampler.type + telemetry.sampler.ratio — triggers
|
|
||||||
// the callback set via SetSamplerReconfigureCallback if the values change.
|
|
||||||
//
|
|
||||||
// The other fields listed in ADR-0023 (api.v2_enabled) remain restart-only
|
|
||||||
// until their handlers land in subsequent phases.
|
|
||||||
//
|
|
||||||
// Stops when ctx is cancelled. Safe to call once at server startup.
|
|
||||||
// If the config file is absent (ConfigFileNotFoundError at load time), this
|
|
||||||
// becomes a no-op and logs a single warning.
|
|
||||||
func (c *Config) WatchAndApply(ctx context.Context) {
|
|
||||||
if c.viper == nil {
|
|
||||||
log.Warn().Msg("Config hot-reload disabled: no viper instance attached")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.viper.ConfigFileUsed() == "" {
|
|
||||||
log.Info().Msg("Config hot-reload disabled: no config file in use (env-only or defaults)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.viper.OnConfigChange(func(in fsnotify.Event) {
|
|
||||||
// Skip processing if watcher has been stopped
|
|
||||||
c.reloadMu.Lock()
|
|
||||||
if c.watcherStopped {
|
|
||||||
c.reloadMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.reloadMu.Unlock()
|
|
||||||
|
|
||||||
log.Info().Str("event", in.Op.String()).Str("file", in.Name).Msg("Config file changed, reloading hot-reloadable fields")
|
|
||||||
c.reloadMu.Lock()
|
|
||||||
defer c.reloadMu.Unlock()
|
|
||||||
|
|
||||||
if err := c.viper.Unmarshal(c); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Hot-reload: failed to unmarshal new config, keeping previous values")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply hot-reloadable fields. Order matters: logging first so the
|
|
||||||
// rest of the reload is logged at the right level.
|
|
||||||
c.SetupLogging()
|
|
||||||
|
|
||||||
// Check if sampler config changed and invoke callback if registered
|
|
||||||
samplerChanged := c.prevSamplerType != c.Telemetry.Sampler.Type ||
|
|
||||||
c.prevSamplerRatio != c.Telemetry.Sampler.Ratio
|
|
||||||
if samplerChanged && c.samplerReconfigureCallback != nil {
|
|
||||||
if err := c.samplerReconfigureCallback(context.Background(),
|
|
||||||
c.Telemetry.Sampler.Type,
|
|
||||||
c.Telemetry.Sampler.Ratio); err != nil {
|
|
||||||
log.Error().Err(err).Msg("Hot-reload: sampler reconfigure callback failed")
|
|
||||||
} else {
|
|
||||||
// Update previous values only after successful callback
|
|
||||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
|
||||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
|
||||||
log.Info().
|
|
||||||
Str("sampler_type", c.prevSamplerType).
|
|
||||||
Float64("sampler_ratio", c.prevSamplerRatio).
|
|
||||||
Msg("Hot-reload applied: telemetry sampler reconfigured")
|
|
||||||
}
|
|
||||||
} else if samplerChanged {
|
|
||||||
// No callback registered, just update tracking values
|
|
||||||
c.prevSamplerType = c.Telemetry.Sampler.Type
|
|
||||||
c.prevSamplerRatio = c.Telemetry.Sampler.Ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("logging_level", c.GetLogLevel()).
|
|
||||||
Dur("jwt_ttl", c.GetJWTTTL()).
|
|
||||||
Msg("Hot-reload applied (logging.level + auth.jwt.ttl)")
|
|
||||||
})
|
|
||||||
c.viper.WatchConfig()
|
|
||||||
|
|
||||||
log.Info().Str("file", c.viper.ConfigFileUsed()).Msg("Config hot-reload watcher started (ADR-0023 Phase 1)")
|
|
||||||
|
|
||||||
// Stop the watcher on context cancel — we set a flag that the
|
|
||||||
// OnConfigChange handler checks, avoiding the race with viper's
|
|
||||||
// internal state that would occur if we called OnConfigChange again.
|
|
||||||
// We deliberately do NOT log here: viper's internal watcher goroutine
|
|
||||||
// has no public Stop, so it can outlive ctx, and a zerolog call here
|
|
||||||
// would race with the next test's LoadConfig → SetupLogging →
|
|
||||||
// zerolog.SetGlobalLevel under -race (observed 2026-05-05).
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
c.reloadMu.Lock()
|
|
||||||
c.watcherStopped = true
|
|
||||||
c.reloadMu.Unlock()
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// loadFromFile is a helper that mimics LoadConfig() for a specific file path
|
|
||||||
// without going through the env-prefix and singleton machinery — keeps the
|
|
||||||
// test hermetic.
|
|
||||||
func loadFromFile(t *testing.T, path string) *Config {
|
|
||||||
t.Helper()
|
|
||||||
v := viper.New()
|
|
||||||
v.SetConfigFile(path)
|
|
||||||
v.SetConfigType("yaml")
|
|
||||||
v.SetDefault("logging.level", "info")
|
|
||||||
v.SetDefault("auth.jwt.ttl", time.Hour)
|
|
||||||
require.NoError(t, v.ReadInConfig())
|
|
||||||
|
|
||||||
c := &Config{viper: v}
|
|
||||||
require.NoError(t, v.Unmarshal(c))
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_LoggingLevel proves the hot-reload pipe end-to-end:
|
|
||||||
// write a new logging.level to the watched file, the OnConfigChange handler
|
|
||||||
// re-unmarshals, and the in-memory Config reflects the new value.
|
|
||||||
func TestWatchAndApply_LoggingLevel(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: info\n"), 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
assert.Equal(t, "info", c.GetLogLevel())
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
// Mutate the file. fsnotify needs a real write event; rewrite atomically.
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: debug\n"), 0644))
|
|
||||||
|
|
||||||
// Poll for up to 2s waiting for the in-memory level to flip.
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
c.reloadMu.RLock()
|
|
||||||
level := c.GetLogLevel()
|
|
||||||
c.reloadMu.RUnlock()
|
|
||||||
if level == "debug" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
c.reloadMu.RLock()
|
|
||||||
defer c.reloadMu.RUnlock()
|
|
||||||
t.Fatalf("logging level did not hot-reload to debug: still %q", c.GetLogLevel())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_NoFileNoOp confirms the watcher is a safe no-op when no
|
|
||||||
// config file is in use (env-only / defaults) — important so production
|
|
||||||
// containers without a mounted config.yaml don't crash.
|
|
||||||
func TestWatchAndApply_NoFileNoOp(t *testing.T) {
|
|
||||||
c := &Config{viper: viper.New()}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx) // should return without panicking
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_NilViperNoOp confirms the watcher tolerates a Config
|
|
||||||
// constructed without the viper field (e.g. tests that build a Config{}
|
|
||||||
// manually — same defensive code path as production but exercised explicitly).
|
|
||||||
func TestWatchAndApply_NilViperNoOp(t *testing.T) {
|
|
||||||
c := &Config{}
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_JWTTTL proves Phase 2 of ADR-0023: the JWT TTL is
|
|
||||||
// re-read on every token generation via the GetJWTTTL method value, so
|
|
||||||
// after a config-file change the new TTL takes effect without restart.
|
|
||||||
func TestWatchAndApply_JWTTTL(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("auth:\n jwt:\n ttl: 1h\n"), 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
assert.Equal(t, time.Hour, c.GetJWTTTL())
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte("auth:\n jwt:\n ttl: 30m\n"), 0644))
|
|
||||||
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
c.reloadMu.RLock()
|
|
||||||
ttl := c.GetJWTTTL()
|
|
||||||
c.reloadMu.RUnlock()
|
|
||||||
if ttl == 30*time.Minute {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
c.reloadMu.RLock()
|
|
||||||
defer c.reloadMu.RUnlock()
|
|
||||||
t.Fatalf("auth.jwt.ttl did not hot-reload to 30m: still %s", c.GetJWTTTL())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_TelemetrySamplerType proves Phase 3 of ADR-0023:
|
|
||||||
// when telemetry.sampler.type changes, the callback registered via
|
|
||||||
// SetSamplerReconfigureCallback is invoked exactly once with the new value.
|
|
||||||
func TestWatchAndApply_TelemetrySamplerType(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 1.0
|
|
||||||
`)
|
|
||||||
changed := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: traceidratio
|
|
||||||
ratio: 1.0
|
|
||||||
`)
|
|
||||||
require.NoError(t, os.WriteFile(path, initial, 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
assert.Equal(t, "parentbased_always_on", c.GetSamplerType())
|
|
||||||
|
|
||||||
// Setup callback tracker
|
|
||||||
var mu sync.Mutex
|
|
||||||
callbackCalled := false
|
|
||||||
var recordedType string
|
|
||||||
var recordedRatio float64
|
|
||||||
c.SetSamplerReconfigureCallback(func(ctx context.Context, samplerType string, samplerRatio float64) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
callbackCalled = true
|
|
||||||
recordedType = samplerType
|
|
||||||
recordedRatio = samplerRatio
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
// Mutate the file
|
|
||||||
require.NoError(t, os.WriteFile(path, changed, 0644))
|
|
||||||
|
|
||||||
// Poll for up to 2s waiting for callback
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
mu.Lock()
|
|
||||||
if callbackCalled {
|
|
||||||
mu.Unlock()
|
|
||||||
assert.Equal(t, "traceidratio", recordedType)
|
|
||||||
assert.Equal(t, 1.0, recordedRatio)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
t.Fatalf("sampler reconfigure callback was not invoked: callbackCalled=%v", callbackCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_TelemetrySamplerRatio proves Phase 3 of ADR-0023:
|
|
||||||
// when telemetry.sampler.ratio changes, the callback registered via
|
|
||||||
// SetSamplerReconfigureCallback is invoked exactly once with the new value.
|
|
||||||
func TestWatchAndApply_TelemetrySamplerRatio(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 1.0
|
|
||||||
`)
|
|
||||||
changed := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 0.5
|
|
||||||
`)
|
|
||||||
require.NoError(t, os.WriteFile(path, initial, 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
assert.Equal(t, 1.0, c.GetSamplerRatio())
|
|
||||||
|
|
||||||
// Setup callback tracker
|
|
||||||
var mu sync.Mutex
|
|
||||||
callbackCalled := false
|
|
||||||
var recordedType string
|
|
||||||
var recordedRatio float64
|
|
||||||
c.SetSamplerReconfigureCallback(func(ctx context.Context, samplerType string, samplerRatio float64) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
callbackCalled = true
|
|
||||||
recordedType = samplerType
|
|
||||||
recordedRatio = samplerRatio
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
// Mutate the file
|
|
||||||
require.NoError(t, os.WriteFile(path, changed, 0644))
|
|
||||||
|
|
||||||
// Poll for up to 2s waiting for callback
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
mu.Lock()
|
|
||||||
if callbackCalled {
|
|
||||||
mu.Unlock()
|
|
||||||
assert.Equal(t, "parentbased_always_on", recordedType)
|
|
||||||
assert.Equal(t, 0.5, recordedRatio)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
t.Fatalf("sampler reconfigure callback was not invoked: callbackCalled=%v", callbackCalled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_SamplerCallbackNotCalledWhenNoChange proves that
|
|
||||||
// the sampler callback is NOT invoked when the config file changes but
|
|
||||||
// sampler type and ratio remain the same.
|
|
||||||
func TestWatchAndApply_SamplerCallbackNotCalledWhenNoChange(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 1.0
|
|
||||||
logging:
|
|
||||||
level: info
|
|
||||||
`)
|
|
||||||
changed := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 1.0
|
|
||||||
logging:
|
|
||||||
level: debug
|
|
||||||
`)
|
|
||||||
require.NoError(t, os.WriteFile(path, initial, 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
|
|
||||||
// Setup callback tracker
|
|
||||||
var mu sync.Mutex
|
|
||||||
callbackCalled := false
|
|
||||||
c.SetSamplerReconfigureCallback(func(ctx context.Context, samplerType string, samplerRatio float64) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
callbackCalled = true
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
// Mutate the file (logging level changes, but sampler stays the same)
|
|
||||||
require.NoError(t, os.WriteFile(path, changed, 0644))
|
|
||||||
|
|
||||||
// Poll for up to 2s - callback should NOT be called
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
mu.Lock()
|
|
||||||
wasCalled := callbackCalled
|
|
||||||
mu.Unlock()
|
|
||||||
if wasCalled {
|
|
||||||
t.Fatalf("sampler reconfigure callback was invoked but sampler did not change")
|
|
||||||
}
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWatchAndApply_SamplerCallbackErrorHandling proves that when the
|
|
||||||
// sampler reconfigure callback returns an error, the previous sampler values
|
|
||||||
// are NOT updated, allowing retry on next config change.
|
|
||||||
func TestWatchAndApply_SamplerCallbackErrorHandling(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
initial := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: parentbased_always_on
|
|
||||||
ratio: 1.0
|
|
||||||
`)
|
|
||||||
changed := []byte(`telemetry:
|
|
||||||
sampler:
|
|
||||||
type: traceidratio
|
|
||||||
ratio: 0.5
|
|
||||||
`)
|
|
||||||
require.NoError(t, os.WriteFile(path, initial, 0644))
|
|
||||||
|
|
||||||
c := loadFromFile(t, path)
|
|
||||||
|
|
||||||
// Setup callback that returns an error
|
|
||||||
expectedErr := errors.New("reconfigure failed")
|
|
||||||
var mu sync.Mutex
|
|
||||||
callbackCalled := false
|
|
||||||
c.SetSamplerReconfigureCallback(func(ctx context.Context, samplerType string, samplerRatio float64) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
callbackCalled = true
|
|
||||||
return expectedErr
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
c.WatchAndApply(ctx)
|
|
||||||
|
|
||||||
// Mutate the file
|
|
||||||
require.NoError(t, os.WriteFile(path, changed, 0644))
|
|
||||||
|
|
||||||
// Poll for up to 2s waiting for callback error
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
mu.Lock()
|
|
||||||
if callbackCalled {
|
|
||||||
mu.Unlock()
|
|
||||||
// Verify previous values were NOT updated (so retry can work)
|
|
||||||
c.reloadMu.RLock()
|
|
||||||
assert.Equal(t, "parentbased_always_on", c.prevSamplerType)
|
|
||||||
assert.Equal(t, 1.0, c.prevSamplerRatio)
|
|
||||||
c.reloadMu.RUnlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
t.Fatalf("sampler reconfigure callback was not invoked: callbackCalled=%v", callbackCalled)
|
|
||||||
}
|
|
||||||
@@ -24,25 +24,13 @@ type JWTSecret struct {
|
|||||||
ExpiresAt *time.Time // Optional expiration time
|
ExpiresAt *time.Time // Optional expiration time
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTSecretManager manages multiple JWT secrets for rotation.
|
// JWTSecretManager manages multiple JWT secrets for rotation
|
||||||
// Secrets can carry an optional expiration; the cleanup loop removes them
|
|
||||||
// after expiry while always preserving the primary secret (ADR-0021).
|
|
||||||
type JWTSecretManager interface {
|
type JWTSecretManager interface {
|
||||||
AddSecret(secret string, isPrimary bool, expiresIn time.Duration)
|
AddSecret(secret string, isPrimary bool, expiresIn time.Duration)
|
||||||
RotateToSecret(newSecret string)
|
RotateToSecret(newSecret string)
|
||||||
GetPrimarySecret() string
|
GetPrimarySecret() string
|
||||||
GetAllValidSecrets() []JWTSecret
|
GetAllValidSecrets() []JWTSecret
|
||||||
GetSecretByIndex(index int) (string, bool)
|
GetSecretByIndex(index int) (string, bool)
|
||||||
|
|
||||||
// RemoveExpiredSecrets drops every non-primary secret whose ExpiresAt is
|
|
||||||
// non-nil and in the past. Returns the count of secrets removed.
|
|
||||||
// The primary secret is never removed regardless of expiration.
|
|
||||||
RemoveExpiredSecrets() int
|
|
||||||
|
|
||||||
// StartCleanupLoop spawns a goroutine that calls RemoveExpiredSecrets at
|
|
||||||
// the given interval. Stops when the context is cancelled. Safe to call
|
|
||||||
// once at startup; calling again replaces the previous loop's context.
|
|
||||||
StartCleanupLoop(ctx context.Context, interval time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTService defines interface for JWT operations
|
// JWTService defines interface for JWT operations
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
package jwt
|
package jwt
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// jwtSecretManagerImpl implements the JWTSecretManager interface.
|
// jwtSecretManagerImpl implements the JWTSecretManager interface
|
||||||
// All operations are mutex-protected so the cleanup goroutine
|
|
||||||
// (StartCleanupLoop) can run alongside Generate / Validate calls.
|
|
||||||
type jwtSecretManagerImpl struct {
|
type jwtSecretManagerImpl struct {
|
||||||
mu sync.Mutex
|
|
||||||
secrets []JWTSecret
|
secrets []JWTSecret
|
||||||
primarySecret string
|
primarySecret string
|
||||||
cleanupCancel context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJWTSecretManager creates a new JWT secret manager.
|
// NewJWTSecretManager creates a new JWT secret manager
|
||||||
func NewJWTSecretManager(initialSecret string) JWTSecretManager {
|
func NewJWTSecretManager(initialSecret string) JWTSecretManager {
|
||||||
return &jwtSecretManagerImpl{
|
return &jwtSecretManagerImpl{
|
||||||
secrets: []JWTSecret{
|
secrets: []JWTSecret{
|
||||||
@@ -32,132 +24,58 @@ func NewJWTSecretManager(initialSecret string) JWTSecretManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSecret adds a new JWT secret.
|
// AddSecret adds a new JWT secret
|
||||||
func (m *jwtSecretManagerImpl) AddSecret(secret string, isPrimary bool, expiresIn time.Duration) {
|
func (m *jwtSecretManagerImpl) AddSecret(secret string, isPrimary bool, expiresIn time.Duration) {
|
||||||
m.mu.Lock()
|
expiresAt := time.Now().Add(expiresIn)
|
||||||
defer m.mu.Unlock()
|
m.secrets = append(m.secrets, JWTSecret{
|
||||||
m.addSecretLocked(secret, isPrimary, expiresIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSecretLocked is the internal helper that assumes the mutex is held.
|
|
||||||
func (m *jwtSecretManagerImpl) addSecretLocked(secret string, isPrimary bool, expiresIn time.Duration) {
|
|
||||||
entry := JWTSecret{
|
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
IsPrimary: isPrimary,
|
IsPrimary: isPrimary,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
ExpiresAt: &expiresAt,
|
||||||
if expiresIn > 0 {
|
})
|
||||||
expiresAt := time.Now().Add(expiresIn)
|
|
||||||
entry.ExpiresAt = &expiresAt
|
|
||||||
}
|
|
||||||
m.secrets = append(m.secrets, entry)
|
|
||||||
|
|
||||||
if isPrimary {
|
if isPrimary {
|
||||||
m.primarySecret = secret
|
m.primarySecret = secret
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotateToSecret rotates to a new primary secret.
|
// RotateToSecret rotates to a new primary secret
|
||||||
func (m *jwtSecretManagerImpl) RotateToSecret(newSecret string) {
|
func (m *jwtSecretManagerImpl) RotateToSecret(newSecret string) {
|
||||||
m.mu.Lock()
|
// Mark existing primary as non-primary
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
for i, secret := range m.secrets {
|
for i, secret := range m.secrets {
|
||||||
if secret.IsPrimary {
|
if secret.IsPrimary {
|
||||||
m.secrets[i].IsPrimary = false
|
m.secrets[i].IsPrimary = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.addSecretLocked(newSecret, true, 0)
|
|
||||||
|
// Add new secret as primary
|
||||||
|
m.AddSecret(newSecret, true, 0) // No expiration for primary
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPrimarySecret returns the current primary secret.
|
// GetPrimarySecret returns the current primary secret
|
||||||
func (m *jwtSecretManagerImpl) GetPrimarySecret() string {
|
func (m *jwtSecretManagerImpl) GetPrimarySecret() string {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.primarySecret
|
return m.primarySecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllValidSecrets returns all valid (non-expired) secrets.
|
// GetAllValidSecrets returns all valid (non-expired) secrets
|
||||||
func (m *jwtSecretManagerImpl) GetAllValidSecrets() []JWTSecret {
|
func (m *jwtSecretManagerImpl) GetAllValidSecrets() []JWTSecret {
|
||||||
m.mu.Lock()
|
var validSecrets []JWTSecret
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
valid := make([]JWTSecret, 0, len(m.secrets))
|
|
||||||
for _, secret := range m.secrets {
|
for _, secret := range m.secrets {
|
||||||
if secret.ExpiresAt == nil || secret.ExpiresAt.After(now) {
|
if secret.ExpiresAt == nil || secret.ExpiresAt.After(now) {
|
||||||
valid = append(valid, secret)
|
validSecrets = append(validSecrets, secret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return valid
|
|
||||||
|
return validSecrets
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretByIndex returns a secret by index for testing.
|
// GetSecretByIndex returns a secret by index for testing
|
||||||
func (m *jwtSecretManagerImpl) GetSecretByIndex(index int) (string, bool) {
|
func (m *jwtSecretManagerImpl) GetSecretByIndex(index int) (string, bool) {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if index < 0 || index >= len(m.secrets) {
|
if index < 0 || index >= len(m.secrets) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return m.secrets[index].Secret, true
|
return m.secrets[index].Secret, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveExpiredSecrets drops every non-primary secret whose ExpiresAt is
|
|
||||||
// non-nil and in the past. Returns the count of secrets removed.
|
|
||||||
// The primary secret is never removed regardless of expiration (ADR-0021).
|
|
||||||
func (m *jwtSecretManagerImpl) RemoveExpiredSecrets() int {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
kept := make([]JWTSecret, 0, len(m.secrets))
|
|
||||||
removed := 0
|
|
||||||
for _, secret := range m.secrets {
|
|
||||||
if !secret.IsPrimary && secret.ExpiresAt != nil && !secret.ExpiresAt.After(now) {
|
|
||||||
removed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, secret)
|
|
||||||
}
|
|
||||||
m.secrets = kept
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCleanupLoop spawns a goroutine that calls RemoveExpiredSecrets at the
|
|
||||||
// given interval. Stops when the parent context is cancelled. Calling again
|
|
||||||
// cancels the previous loop's context and starts a fresh one.
|
|
||||||
func (m *jwtSecretManagerImpl) StartCleanupLoop(ctx context.Context, interval time.Duration) {
|
|
||||||
m.mu.Lock()
|
|
||||||
if m.cleanupCancel != nil {
|
|
||||||
m.cleanupCancel()
|
|
||||||
}
|
|
||||||
loopCtx, cancel := context.WithCancel(ctx)
|
|
||||||
m.cleanupCancel = cancel
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if interval <= 0 {
|
|
||||||
log.Warn().Dur("interval", interval).Msg("JWT secret cleanup interval is non-positive, loop disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
log.Info().Dur("interval", interval).Msg("JWT secret cleanup loop started")
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-loopCtx.Done():
|
|
||||||
log.Info().Msg("JWT secret cleanup loop stopped")
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
removed := m.RemoveExpiredSecrets()
|
|
||||||
if removed > 0 {
|
|
||||||
log.Info().Int("removed", removed).Msg("JWT secrets cleaned up")
|
|
||||||
} else {
|
|
||||||
log.Trace().Msg("JWT cleanup tick: no expired secrets")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,21 +71,7 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||||
// Initialize default user repository and services (Postgres from cfg)
|
// Create validator instance
|
||||||
userRepo, userService, err := initializeUserServices(cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
|
|
||||||
}
|
|
||||||
return NewServerWithUserRepo(cfg, readyCtx, userRepo, userService)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewServerWithUserRepo builds a Server with caller-provided userRepo + userService.
|
|
||||||
// Used by BDD test infra to inject a per-scenario repository (e.g., one connected
|
|
||||||
// to an isolated PostgreSQL schema). Pass nil for both to disable user functionality.
|
|
||||||
//
|
|
||||||
// The validator + cache services are still built from cfg internally; they don't
|
|
||||||
// need per-scenario isolation today.
|
|
||||||
func NewServerWithUserRepo(cfg *config.Config, readyCtx context.Context, userRepo user.UserRepository, userService user.UserService) *Server {
|
|
||||||
validator, err := validation.GetValidatorFromConfig(cfg)
|
validator, err := validation.GetValidatorFromConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to create validator, continuing without validation")
|
log.Error().Err(err).Msg("Failed to create validator, continuing without validation")
|
||||||
@@ -93,6 +79,13 @@ func NewServerWithUserRepo(cfg *config.Config, readyCtx context.Context, userRep
|
|||||||
log.Trace().Msg("Validator created successfully")
|
log.Trace().Msg("Validator created successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize user repository and services
|
||||||
|
userRepo, userService, err := initializeUserServices(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize cache service
|
||||||
var cacheService cache.Service
|
var cacheService cache.Service
|
||||||
if cfg.GetCacheEnabled() {
|
if cfg.GetCacheEnabled() {
|
||||||
cacheService = cache.NewInMemoryService(
|
cacheService = cache.NewInMemoryService(
|
||||||
@@ -125,12 +118,6 @@ func (s *Server) GetAuthService() user.AuthService {
|
|||||||
return s.userService
|
return s.userService
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCacheService returns the cache service for test cleanup
|
|
||||||
// This allows test suites to flush cache between tests
|
|
||||||
func (s *Server) GetCacheService() cache.Service {
|
|
||||||
return s.cacheService
|
|
||||||
}
|
|
||||||
|
|
||||||
// initializeUserServices initializes the user repository and unified user service
|
// initializeUserServices initializes the user repository and unified user service
|
||||||
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, error) {
|
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, error) {
|
||||||
// Create user repository using PostgreSQL
|
// Create user repository using PostgreSQL
|
||||||
@@ -139,16 +126,10 @@ func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserS
|
|||||||
return nil, nil, fmt.Errorf("failed to create PostgreSQL user repository: %w", err)
|
return nil, nil, fmt.Errorf("failed to create PostgreSQL user repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create JWT config.
|
// Create JWT config
|
||||||
// GetTTL is a method value — it captures cfg, so when WatchAndApply
|
|
||||||
// re-unmarshals into the same Config struct on file changes, every
|
|
||||||
// subsequent token generation reads the new TTL (ADR-0023 Phase 2).
|
|
||||||
// ExpirationTime is kept as a static fallback for tests that build
|
|
||||||
// JWTConfig manually without a Config.
|
|
||||||
jwtConfig := user.JWTConfig{
|
jwtConfig := user.JWTConfig{
|
||||||
Secret: cfg.GetJWTSecret(),
|
Secret: cfg.GetJWTSecret(),
|
||||||
ExpirationTime: 24 * time.Hour,
|
ExpirationTime: time.Hour * 24, // 24 hours
|
||||||
GetTTL: cfg.GetJWTTTL,
|
|
||||||
Issuer: "dance-lessons-coach",
|
Issuer: "dance-lessons-coach",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,21 +158,12 @@ func (s *Server) setupRoutes() {
|
|||||||
// Kubernetes-style health endpoint at root level
|
// Kubernetes-style health endpoint at root level
|
||||||
s.router.Get("/api/healthz", s.handleHealthz)
|
s.router.Get("/api/healthz", s.handleHealthz)
|
||||||
|
|
||||||
// Info endpoint - composite aggregator
|
|
||||||
s.router.Get("/api/info", s.handleInfo)
|
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
s.router.Route("/api/v1", func(r chi.Router) {
|
s.router.Route("/api/v1", func(r chi.Router) {
|
||||||
r.Use(s.getAllMiddlewares()...)
|
r.Use(s.getAllMiddlewares()...)
|
||||||
s.registerApiV1Routes(r)
|
s.registerApiV1Routes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Admin routes
|
|
||||||
s.router.Route("/api/admin", func(r chi.Router) {
|
|
||||||
r.Use(s.getAllMiddlewares()...)
|
|
||||||
r.Post("/cache/flush", s.handleAdminCacheFlush)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Register v2 routes if enabled
|
// Register v2 routes if enabled
|
||||||
if s.config.GetV2Enabled() {
|
if s.config.GetV2Enabled() {
|
||||||
s.router.Route("/api/v2", func(r chi.Router) {
|
s.router.Route("/api/v2", func(r chi.Router) {
|
||||||
@@ -218,6 +190,9 @@ func (s *Server) setupRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) registerApiV1Routes(r chi.Router) {
|
func (s *Server) registerApiV1Routes(r chi.Router) {
|
||||||
|
greetService := greet.NewService()
|
||||||
|
greetHandler := greet.NewApiV1GreetHandler(greetService)
|
||||||
|
|
||||||
// Create rate limit middleware
|
// Create rate limit middleware
|
||||||
rateLimitMiddleware := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
rateLimitMiddleware := middleware.NewRateLimiter(middleware.RateLimitConfig{
|
||||||
Enabled: s.config.GetRateLimitEnabled(),
|
Enabled: s.config.GetRateLimitEnabled(),
|
||||||
@@ -238,8 +213,7 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
|
|||||||
if authMiddleware != nil {
|
if authMiddleware != nil {
|
||||||
r.Use(authMiddleware.Middleware)
|
r.Use(authMiddleware.Middleware)
|
||||||
}
|
}
|
||||||
r.Get("/", s.handleGreetQuery)
|
greetHandler.RegisterRoutes(r)
|
||||||
r.Get("/{name}", s.handleGreetPath)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register user authentication routes
|
// Register user authentication routes
|
||||||
@@ -445,16 +419,6 @@ type HealthzResponse struct {
|
|||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InfoResponse represents the JSON response for /api/info
|
|
||||||
type InfoResponse struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
CommitShort string `json:"commit_short"`
|
|
||||||
BuildDate string `json:"build_date"`
|
|
||||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
|
||||||
CacheEnabled bool `json:"cache_enabled"`
|
|
||||||
HealthzStatus string `json:"healthz_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleHealthz godoc
|
// handleHealthz godoc
|
||||||
//
|
//
|
||||||
// @Summary Kubernetes-style health check
|
// @Summary Kubernetes-style health check
|
||||||
@@ -475,202 +439,6 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleInfo godoc
|
|
||||||
//
|
|
||||||
// @Summary Get composite info
|
|
||||||
// @Description Returns aggregated version, build, uptime, cache, and health info
|
|
||||||
// @Tags System/Info
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} InfoResponse
|
|
||||||
// @Router /info [get]
|
|
||||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Trace().Msg("Info endpoint requested")
|
|
||||||
|
|
||||||
// Build commit_short from version.Commit (first 8 chars if available)
|
|
||||||
commitShort := version.Commit
|
|
||||||
if len(commitShort) > 8 {
|
|
||||||
commitShort = commitShort[:8]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build response
|
|
||||||
resp := InfoResponse{
|
|
||||||
Version: version.Version,
|
|
||||||
CommitShort: commitShort,
|
|
||||||
BuildDate: version.Date,
|
|
||||||
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
|
|
||||||
CacheEnabled: s.cacheService != nil,
|
|
||||||
HealthzStatus: "healthy",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache key
|
|
||||||
cacheKey := "info:json"
|
|
||||||
|
|
||||||
// Check cache if enabled
|
|
||||||
if s.cacheService != nil {
|
|
||||||
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for info")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("X-Cache", "HIT")
|
|
||||||
w.Write([]byte(cached.(string)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal response
|
|
||||||
data, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the response
|
|
||||||
if s.cacheService != nil {
|
|
||||||
s.cacheService.Set(cacheKey, string(data),
|
|
||||||
time.Duration(s.config.GetCacheDefaultTTLSeconds())*time.Second)
|
|
||||||
w.Header().Set("X-Cache", "MISS")
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cached info response")
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGreetQuery godoc
|
|
||||||
//
|
|
||||||
// @Summary Get greeting with cache
|
|
||||||
// @Description Returns greeting for name from query param with caching
|
|
||||||
// @Tags API/v1/Greeting
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param name query string false "Name to greet"
|
|
||||||
// @Success 200 {object} map[string]string "Greeting message"
|
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
|
||||||
// @Router /v1/greet [get]
|
|
||||||
func (s *Server) handleGreetQuery(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := r.URL.Query().Get("name")
|
|
||||||
cacheKey := "greet:v1:" + name
|
|
||||||
|
|
||||||
// Check cache if enabled
|
|
||||||
if s.cacheService != nil {
|
|
||||||
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for greet")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("X-Cache", "HIT")
|
|
||||||
w.Write([]byte(cached.(string)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute response
|
|
||||||
greetService := greet.NewService()
|
|
||||||
message := greetService.Greet(r.Context(), name)
|
|
||||||
response, err := json.Marshal(map[string]string{"message": message})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the response for 60 seconds if cache is enabled
|
|
||||||
if s.cacheService != nil {
|
|
||||||
s.cacheService.Set(cacheKey, string(response), 60*time.Second)
|
|
||||||
w.Header().Set("X-Cache", "MISS")
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cached greet response")
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGreetPath godoc
|
|
||||||
//
|
|
||||||
// @Summary Get personalized greeting with cache
|
|
||||||
// @Description Returns greeting for name from path param with caching
|
|
||||||
// @Tags API/v1/Greeting
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param name path string true "Name to greet"
|
|
||||||
// @Success 200 {object} map[string]string "Greeting message"
|
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
|
||||||
// @Router /v1/greet/{name} [get]
|
|
||||||
func (s *Server) handleGreetPath(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := chi.URLParam(r, "name")
|
|
||||||
cacheKey := "greet:v1:" + name
|
|
||||||
|
|
||||||
// Check cache if enabled
|
|
||||||
if s.cacheService != nil {
|
|
||||||
if cached, ok := s.cacheService.Get(cacheKey); ok {
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for greet")
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("X-Cache", "HIT")
|
|
||||||
w.Write([]byte(cached.(string)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute response
|
|
||||||
greetService := greet.NewService()
|
|
||||||
message := greetService.Greet(r.Context(), name)
|
|
||||||
response, err := json.Marshal(map[string]string{"message": message})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the response for 60 seconds if cache is enabled
|
|
||||||
if s.cacheService != nil {
|
|
||||||
s.cacheService.Set(cacheKey, string(response), 60*time.Second)
|
|
||||||
w.Header().Set("X-Cache", "MISS")
|
|
||||||
log.Trace().Str("cache_key", cacheKey).Msg("Cached greet response")
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdminCacheFlush godoc
|
|
||||||
//
|
|
||||||
// @Summary Flush cache
|
|
||||||
// @Description Flushes the entire cache, requires admin authentication
|
|
||||||
// @Tags API/Admin
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param X-Admin-Password header string true "Admin master password"
|
|
||||||
// @Success 200 {object} map[string]interface{} "Cache flushed successfully"
|
|
||||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
|
||||||
// @Failure 503 {object} map[string]string "Cache disabled"
|
|
||||||
// @Router /admin/cache/flush [post]
|
|
||||||
func (s *Server) handleAdminCacheFlush(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.cacheService == nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "cache_disabled"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin auth - check X-Admin-Password header
|
|
||||||
masterPassword := r.Header.Get("X-Admin-Password")
|
|
||||||
if masterPassword == "" {
|
|
||||||
http.Error(w, `{"error":"unauthorized","message":"Admin password required"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.userService.AdminAuthenticate(r.Context(), masterPassword)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, `{"error":"unauthorized","message":"Invalid admin password"}`, http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
itemCount := s.cacheService.ItemCount()
|
|
||||||
s.cacheService.Flush()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"flushed": true,
|
|
||||||
"items_flushed": itemCount,
|
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) Router() http.Handler {
|
func (s *Server) Router() http.Handler {
|
||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
@@ -707,17 +475,6 @@ func (s *Server) Run() error {
|
|||||||
ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background())
|
ongoingCtx, stopOngoingGracefully := context.WithCancel(context.Background())
|
||||||
defer stopOngoingGracefully()
|
defer stopOngoingGracefully()
|
||||||
|
|
||||||
// Start the JWT secret cleanup loop (ADR-0021). The loop runs until rootCtx
|
|
||||||
// is cancelled (graceful shutdown), removing non-primary secrets whose
|
|
||||||
// ExpiresAt is in the past.
|
|
||||||
if s.userService != nil {
|
|
||||||
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start config hot-reload watcher (ADR-0023 Phase 1: logging.level only).
|
|
||||||
// Stops automatically on rootCtx cancellation.
|
|
||||||
s.config.WatchAndApply(rootCtx)
|
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
log.Trace().Str("address", s.config.GetServerAddress()).Msg("Server running")
|
log.Trace().Str("address", s.config.GetServerAddress()).Msg("Server running")
|
||||||
|
|
||||||
|
|||||||
@@ -74,36 +74,6 @@ func Shutdown(ctx context.Context, tp *sdktrace.TracerProvider) error {
|
|||||||
return tp.Shutdown(ctx)
|
return tp.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconfigureTracerProvider rebuilds the global tracer provider with the
|
|
||||||
// updated sampler settings (ADR-0023 Phase 3 hot-reload). The previous
|
|
||||||
// provider is gracefully shut down so in-flight spans are flushed.
|
|
||||||
//
|
|
||||||
// No-op if oldTP is nil — telemetry was disabled at startup, hot-reloading
|
|
||||||
// it on would require a different code path (out of scope for Phase 3).
|
|
||||||
//
|
|
||||||
// Returns the new TracerProvider so the caller can track it for the next
|
|
||||||
// shutdown / reconfigure cycle. On error the old TP is left in place.
|
|
||||||
func (s *Setup) ReconfigureTracerProvider(ctx context.Context, oldTP *sdktrace.TracerProvider) (*sdktrace.TracerProvider, error) {
|
|
||||||
if oldTP == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the new provider first — if anything fails we keep the old TP active.
|
|
||||||
newTP, err := s.InitializeTracing(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitializeTracing already swapped the global provider via otel.SetTracerProvider,
|
|
||||||
// so the new one is now active. Drain the old one so no spans are lost.
|
|
||||||
if shutdownErr := oldTP.Shutdown(ctx); shutdownErr != nil {
|
|
||||||
// Log via the standard logger — zerolog isn't imported in this package.
|
|
||||||
log.Printf("ReconfigureTracerProvider: old TP shutdown failed: %v (new TP is active)", shutdownErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTP, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSampler returns the appropriate sampler based on configuration
|
// getSampler returns the appropriate sampler based on configuration
|
||||||
func (s *Setup) getSampler() sdktrace.Sampler {
|
func (s *Setup) getSampler() sdktrace.Sampler {
|
||||||
switch s.SamplerType {
|
switch s.SamplerType {
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
package telemetry
|
|
||||||
|
|
||||||
// All tests in this file mutate the OpenTelemetry global tracer provider via
|
|
||||||
// otel.SetTracerProvider (called by InitializeTracing / ReconfigureTracerProvider).
|
|
||||||
// They MUST NOT be parallelized — t.Parallel() would race on the global state.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestReconfigureTracerProvider_NilOldNoOp confirms that hot-reload is a
|
|
||||||
// no-op when telemetry was never initialized at startup. Hot-reloading
|
|
||||||
// telemetry-on requires a different code path that's out of scope for
|
|
||||||
// ADR-0023 Phase 3.
|
|
||||||
func TestReconfigureTracerProvider_NilOldNoOp(t *testing.T) {
|
|
||||||
s := &Setup{
|
|
||||||
ServiceName: "test",
|
|
||||||
OTLPEndpoint: "localhost:4317",
|
|
||||||
Insecure: true,
|
|
||||||
SamplerType: "always_on",
|
|
||||||
SamplerRatio: 1.0,
|
|
||||||
Version: "test",
|
|
||||||
}
|
|
||||||
newTP, err := s.ReconfigureTracerProvider(context.Background(), nil)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Nil(t, newTP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconfigureTracerProvider_SwapsGlobal confirms that after a successful
|
|
||||||
// reconfigure, the global otel tracer provider points to the new one and the
|
|
||||||
// old one was shut down (Shutdown returns nil even after a second Shutdown,
|
|
||||||
// so we just verify no error path hit).
|
|
||||||
func TestReconfigureTracerProvider_SwapsGlobal(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
s := &Setup{
|
|
||||||
ServiceName: "test",
|
|
||||||
OTLPEndpoint: "localhost:4317",
|
|
||||||
Insecure: true,
|
|
||||||
SamplerType: "always_on",
|
|
||||||
SamplerRatio: 1.0,
|
|
||||||
Version: "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
oldTP, err := s.InitializeTracing(ctx)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, oldTP)
|
|
||||||
t.Cleanup(func() { _ = oldTP.Shutdown(ctx) }) // belt-and-braces, harmless if already shut down
|
|
||||||
|
|
||||||
// Mutate sampler before reconfigure
|
|
||||||
s.SamplerType = "traceidratio"
|
|
||||||
s.SamplerRatio = 0.25
|
|
||||||
|
|
||||||
newTP, err := s.ReconfigureTracerProvider(ctx, oldTP)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, newTP)
|
|
||||||
t.Cleanup(func() { _ = newTP.Shutdown(ctx) })
|
|
||||||
|
|
||||||
// otel.GetTracerProvider returns a TracerProvider interface — pointer equality
|
|
||||||
// against newTP is the strongest assertion available without sdk-private state.
|
|
||||||
gotTP := otel.GetTracerProvider()
|
|
||||||
assert.Same(t, newTP, gotTP, "global tracer provider should be the new TP")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconfigureTracerProvider_OldShutdownErrorDoesNotFailReconfigure
|
|
||||||
// confirms that even if shutting down the old TP fails, the new TP is still
|
|
||||||
// returned and active. We simulate this by passing an already-shut-down
|
|
||||||
// provider as oldTP — its second Shutdown is harmless on the SDK but
|
|
||||||
// exercises the error-tolerance path.
|
|
||||||
func TestReconfigureTracerProvider_OldShutdownErrorDoesNotFailReconfigure(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
s := &Setup{
|
|
||||||
ServiceName: "test",
|
|
||||||
OTLPEndpoint: "localhost:4317",
|
|
||||||
Insecure: true,
|
|
||||||
SamplerType: "always_on",
|
|
||||||
SamplerRatio: 1.0,
|
|
||||||
Version: "test",
|
|
||||||
}
|
|
||||||
|
|
||||||
oldTP, err := s.InitializeTracing(ctx)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_ = oldTP.Shutdown(ctx) // pre-shutdown: subsequent Shutdown is documented to return nil
|
|
||||||
|
|
||||||
newTP, err := s.ReconfigureTracerProvider(ctx, oldTP)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, newTP)
|
|
||||||
t.Cleanup(func() { _ = newTP.Shutdown(ctx) })
|
|
||||||
}
|
|
||||||
@@ -11,30 +11,13 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWTConfig holds JWT configuration.
|
// JWTConfig holds JWT configuration
|
||||||
//
|
|
||||||
// GetTTL, when non-nil, is called on every token generation to read the
|
|
||||||
// current TTL — this enables ADR-0023 Phase 2 hot-reload of `auth.jwt.ttl`.
|
|
||||||
// If nil, ExpirationTime is used as a static fallback.
|
|
||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
Secret string
|
Secret string
|
||||||
ExpirationTime time.Duration
|
ExpirationTime time.Duration
|
||||||
GetTTL func() time.Duration
|
|
||||||
Issuer string
|
Issuer string
|
||||||
}
|
}
|
||||||
|
|
||||||
// effectiveTTL returns the live TTL: GetTTL() when wired, else
|
|
||||||
// ExpirationTime as a static fallback (used by tests that don't go
|
|
||||||
// through the server-level wiring).
|
|
||||||
func (c JWTConfig) effectiveTTL() time.Duration {
|
|
||||||
if c.GetTTL != nil {
|
|
||||||
if ttl := c.GetTTL(); ttl > 0 {
|
|
||||||
return ttl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.ExpirationTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// userServiceImpl implements the unified UserService interface
|
// userServiceImpl implements the unified UserService interface
|
||||||
type userServiceImpl struct {
|
type userServiceImpl struct {
|
||||||
repo UserRepository
|
repo UserRepository
|
||||||
@@ -86,7 +69,7 @@ func (s *userServiceImpl) GenerateJWT(ctx context.Context, user *User) (string,
|
|||||||
"sub": user.ID,
|
"sub": user.ID,
|
||||||
"name": user.Username,
|
"name": user.Username,
|
||||||
"admin": user.IsAdmin,
|
"admin": user.IsAdmin,
|
||||||
"exp": time.Now().Add(s.jwtConfig.effectiveTTL()).Unix(),
|
"exp": time.Now().Add(s.jwtConfig.ExpirationTime).Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
"iss": s.jwtConfig.Issuer,
|
"iss": s.jwtConfig.Issuer,
|
||||||
}
|
}
|
||||||
@@ -235,18 +218,6 @@ func (s *userServiceImpl) ResetJWTSecrets() {
|
|||||||
s.secretManager.Reset(s.jwtConfig.Secret)
|
s.secretManager.Reset(s.jwtConfig.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartJWTSecretCleanupLoop delegates to the underlying secret manager to
|
|
||||||
// start the periodic cleanup goroutine described in ADR-0021.
|
|
||||||
func (s *userServiceImpl) StartJWTSecretCleanupLoop(ctx context.Context, interval time.Duration) {
|
|
||||||
s.secretManager.StartCleanupLoop(ctx, interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveExpiredJWTSecrets triggers an immediate cleanup pass via the
|
|
||||||
// underlying secret manager. Returns the count of removed expired secrets.
|
|
||||||
func (s *userServiceImpl) RemoveExpiredJWTSecrets() int {
|
|
||||||
return s.secretManager.RemoveExpiredSecrets()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserExists checks if a user exists by username
|
// UserExists checks if a user exists by username
|
||||||
func (s *userServiceImpl) UserExists(ctx context.Context, username string) (bool, error) {
|
func (s *userServiceImpl) UserExists(ctx context.Context, username string) (bool, error) {
|
||||||
return s.repo.UserExists(ctx, username)
|
return s.repo.UserExists(ctx, username)
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWTSecret represents a JWT secret with metadata
|
// JWTSecret represents a JWT secret with metadata
|
||||||
@@ -16,16 +12,10 @@ type JWTSecret struct {
|
|||||||
ExpiresAt *time.Time // Optional expiration time
|
ExpiresAt *time.Time // Optional expiration time
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTSecretManager manages multiple JWT secrets for rotation.
|
// JWTSecretManager manages multiple JWT secrets for rotation
|
||||||
// All operations are mutex-protected so the cleanup goroutine
|
|
||||||
// (StartCleanupLoop) can run alongside Generate / Validate calls.
|
|
||||||
// ADR-0021 implements automatic removal of expired secrets while
|
|
||||||
// always preserving the primary secret.
|
|
||||||
type JWTSecretManager struct {
|
type JWTSecretManager struct {
|
||||||
mu sync.Mutex
|
|
||||||
secrets []JWTSecret
|
secrets []JWTSecret
|
||||||
primarySecret string
|
primarySecret string
|
||||||
cleanupCancel context.CancelFunc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewJWTSecretManager creates a new JWT secret manager
|
// NewJWTSecretManager creates a new JWT secret manager
|
||||||
@@ -44,19 +34,12 @@ func NewJWTSecretManager(initialSecret string) *JWTSecretManager {
|
|||||||
|
|
||||||
// AddSecret adds a new JWT secret
|
// AddSecret adds a new JWT secret
|
||||||
func (m *JWTSecretManager) AddSecret(secret string, isPrimary bool, expiresIn time.Duration) {
|
func (m *JWTSecretManager) AddSecret(secret string, isPrimary bool, expiresIn time.Duration) {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
m.addSecretLocked(secret, isPrimary, expiresIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addSecretLocked is the internal helper that assumes the mutex is held.
|
|
||||||
func (m *JWTSecretManager) addSecretLocked(secret string, isPrimary bool, expiresIn time.Duration) {
|
|
||||||
var expiresAt *time.Time
|
var expiresAt *time.Time
|
||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
expirationTime := time.Now().Add(expiresIn)
|
expirationTime := time.Now().Add(expiresIn)
|
||||||
expiresAt = &expirationTime
|
expiresAt = &expirationTime
|
||||||
}
|
}
|
||||||
// expiresIn <= 0 means no expiration
|
// If expiresIn is 0 or negative, expiresAt remains nil (no expiration)
|
||||||
|
|
||||||
m.secrets = append(m.secrets, JWTSecret{
|
m.secrets = append(m.secrets, JWTSecret{
|
||||||
Secret: secret,
|
Secret: secret,
|
||||||
@@ -72,60 +55,48 @@ func (m *JWTSecretManager) addSecretLocked(secret string, isPrimary bool, expire
|
|||||||
|
|
||||||
// RotateToSecret rotates to a new primary secret
|
// RotateToSecret rotates to a new primary secret
|
||||||
func (m *JWTSecretManager) RotateToSecret(newSecret string) {
|
func (m *JWTSecretManager) RotateToSecret(newSecret string) {
|
||||||
m.mu.Lock()
|
// Mark existing primary as non-primary
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
for i, secret := range m.secrets {
|
for i, secret := range m.secrets {
|
||||||
if secret.IsPrimary {
|
if secret.IsPrimary {
|
||||||
m.secrets[i].IsPrimary = false
|
m.secrets[i].IsPrimary = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.addSecretLocked(newSecret, true, 0)
|
|
||||||
|
// Add new secret as primary
|
||||||
|
m.AddSecret(newSecret, true, 0) // No expiration for primary
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPrimarySecret returns the current primary secret
|
// GetPrimarySecret returns the current primary secret
|
||||||
func (m *JWTSecretManager) GetPrimarySecret() string {
|
func (m *JWTSecretManager) GetPrimarySecret() string {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
return m.primarySecret
|
return m.primarySecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllValidSecrets returns all valid (non-expired) secrets
|
// GetAllValidSecrets returns all valid (non-expired) secrets
|
||||||
func (m *JWTSecretManager) GetAllValidSecrets() []JWTSecret {
|
func (m *JWTSecretManager) GetAllValidSecrets() []JWTSecret {
|
||||||
m.mu.Lock()
|
var validSecrets []JWTSecret
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
valid := make([]JWTSecret, 0, len(m.secrets))
|
|
||||||
for _, secret := range m.secrets {
|
for _, secret := range m.secrets {
|
||||||
if secret.ExpiresAt == nil || secret.ExpiresAt.After(now) {
|
if secret.ExpiresAt == nil || secret.ExpiresAt.After(now) {
|
||||||
valid = append(valid, secret)
|
validSecrets = append(validSecrets, secret)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return valid
|
|
||||||
|
return validSecrets
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretByIndex returns a secret by index for testing
|
// GetSecretByIndex returns a secret by index for testing
|
||||||
func (m *JWTSecretManager) GetSecretByIndex(index int) (string, bool) {
|
func (m *JWTSecretManager) GetSecretByIndex(index int) (string, bool) {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if index < 0 || index >= len(m.secrets) {
|
if index < 0 || index >= len(m.secrets) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return m.secrets[index].Secret, true
|
return m.secrets[index].Secret, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset resets the secret manager to its initial state with only the primary
|
// Reset resets the secret manager to its initial state with only the primary secret
|
||||||
// secret. Used for test cleanup so tests don't interfere with each other.
|
// This is useful for test cleanup to ensure tests don't interfere with each other
|
||||||
func (m *JWTSecretManager) Reset(initialSecret string) {
|
func (m *JWTSecretManager) Reset(initialSecret string) {
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.cleanupCancel != nil {
|
|
||||||
m.cleanupCancel()
|
|
||||||
m.cleanupCancel = nil
|
|
||||||
}
|
|
||||||
m.secrets = []JWTSecret{
|
m.secrets = []JWTSecret{
|
||||||
{
|
{
|
||||||
Secret: initialSecret,
|
Secret: initialSecret,
|
||||||
@@ -135,64 +106,3 @@ func (m *JWTSecretManager) Reset(initialSecret string) {
|
|||||||
}
|
}
|
||||||
m.primarySecret = initialSecret
|
m.primarySecret = initialSecret
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveExpiredSecrets drops every non-primary secret whose ExpiresAt is
|
|
||||||
// non-nil and in the past. Returns the count of secrets removed.
|
|
||||||
// The primary secret is never removed regardless of expiration (ADR-0021).
|
|
||||||
func (m *JWTSecretManager) RemoveExpiredSecrets() int {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
kept := make([]JWTSecret, 0, len(m.secrets))
|
|
||||||
removed := 0
|
|
||||||
for _, secret := range m.secrets {
|
|
||||||
if !secret.IsPrimary && secret.ExpiresAt != nil && !secret.ExpiresAt.After(now) {
|
|
||||||
removed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, secret)
|
|
||||||
}
|
|
||||||
m.secrets = kept
|
|
||||||
return removed
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCleanupLoop spawns a goroutine that calls RemoveExpiredSecrets at the
|
|
||||||
// given interval. Stops when the parent context is cancelled. Calling again
|
|
||||||
// cancels the previous loop's context and starts a fresh one.
|
|
||||||
// If interval <= 0, the loop is disabled (cleanup must be triggered manually
|
|
||||||
// via RemoveExpiredSecrets).
|
|
||||||
func (m *JWTSecretManager) StartCleanupLoop(ctx context.Context, interval time.Duration) {
|
|
||||||
m.mu.Lock()
|
|
||||||
if m.cleanupCancel != nil {
|
|
||||||
m.cleanupCancel()
|
|
||||||
}
|
|
||||||
loopCtx, cancel := context.WithCancel(ctx)
|
|
||||||
m.cleanupCancel = cancel
|
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
if interval <= 0 {
|
|
||||||
log.Warn().Dur("interval", interval).Msg("JWT secret cleanup interval is non-positive, loop disabled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
log.Info().Dur("interval", interval).Msg("JWT secret cleanup loop started")
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-loopCtx.Done():
|
|
||||||
log.Info().Msg("JWT secret cleanup loop stopped")
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
removed := m.RemoveExpiredSecrets()
|
|
||||||
if removed > 0 {
|
|
||||||
log.Info().Int("removed", removed).Msg("JWT secrets cleaned up")
|
|
||||||
} else {
|
|
||||||
log.Trace().Msg("JWT cleanup tick: no expired secrets")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -85,73 +84,3 @@ func TestJWTSecretExpiration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.True(t, foundExpiring)
|
assert.True(t, foundExpiring)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveExpiredSecrets_ExpiredNonPrimaryRemoved confirms that
|
|
||||||
// RemoveExpiredSecrets drops a non-primary secret whose ExpiresAt is in the past.
|
|
||||||
func TestRemoveExpiredSecrets_ExpiredNonPrimaryRemoved(t *testing.T) {
|
|
||||||
manager := NewJWTSecretManager("primary")
|
|
||||||
|
|
||||||
// Add a secret that expired 1 hour ago by setting expiresIn to a small
|
|
||||||
// positive duration then mutating after via AddSecret + manipulation.
|
|
||||||
// Simpler: add with a 1ns lifetime and sleep 2ns equivalent (tiny TTL).
|
|
||||||
manager.AddSecret("about-to-expire", false, 1*time.Nanosecond)
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
|
|
||||||
removed := manager.RemoveExpiredSecrets()
|
|
||||||
assert.Equal(t, 1, removed, "one expired secret should be removed")
|
|
||||||
|
|
||||||
secrets := manager.GetAllValidSecrets()
|
|
||||||
assert.Len(t, secrets, 1, "only primary should remain")
|
|
||||||
assert.Equal(t, "primary", secrets[0].Secret)
|
|
||||||
assert.True(t, secrets[0].IsPrimary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveExpiredSecrets_PrimaryNeverRemoved confirms the primary secret
|
|
||||||
// is preserved even if (somehow) marked expired - ADR-0021 invariant.
|
|
||||||
func TestRemoveExpiredSecrets_PrimaryNeverRemoved(t *testing.T) {
|
|
||||||
manager := NewJWTSecretManager("primary")
|
|
||||||
|
|
||||||
// Add a non-primary that doesn't expire
|
|
||||||
manager.AddSecret("kept", false, 0)
|
|
||||||
|
|
||||||
// Simulate an "expired primary" by manipulating internals via Reset then
|
|
||||||
// re-creating - here we rely on the public contract: primary has no
|
|
||||||
// ExpiresAt by default. Confirm cleanup leaves it.
|
|
||||||
removed := manager.RemoveExpiredSecrets()
|
|
||||||
assert.Equal(t, 0, removed)
|
|
||||||
|
|
||||||
assert.Equal(t, "primary", manager.GetPrimarySecret())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveExpiredSecrets_NonExpiredKept confirms a future-expiring secret
|
|
||||||
// stays after cleanup.
|
|
||||||
func TestRemoveExpiredSecrets_NonExpiredKept(t *testing.T) {
|
|
||||||
manager := NewJWTSecretManager("primary")
|
|
||||||
manager.AddSecret("future", false, 1*time.Hour)
|
|
||||||
|
|
||||||
removed := manager.RemoveExpiredSecrets()
|
|
||||||
assert.Equal(t, 0, removed)
|
|
||||||
assert.Len(t, manager.GetAllValidSecrets(), 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStartCleanupLoop_FiresAndStops confirms the goroutine actually calls
|
|
||||||
// RemoveExpiredSecrets on each tick and stops cleanly when the context is
|
|
||||||
// cancelled. Uses a short interval to keep the test fast.
|
|
||||||
func TestStartCleanupLoop_FiresAndStops(t *testing.T) {
|
|
||||||
manager := NewJWTSecretManager("primary")
|
|
||||||
manager.AddSecret("dies", false, 5*time.Millisecond)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
manager.StartCleanupLoop(ctx, 10*time.Millisecond)
|
|
||||||
|
|
||||||
// Wait long enough for at least one tick + the secret's TTL
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
cancel() // stop the loop
|
|
||||||
|
|
||||||
secrets := manager.GetAllValidSecrets()
|
|
||||||
assert.Len(t, secrets, 1, "expired secret should have been removed by the loop")
|
|
||||||
assert.Equal(t, "primary", secrets[0].Secret)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -125,75 +125,6 @@ func NewPostgresRepository(cfg *config.Config) (*PostgresRepository, error) {
|
|||||||
return repo, nil
|
return repo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostgresRepositoryFromDSN creates a PostgresRepository connected via the given DSN
|
|
||||||
// and runs AutoMigrate against it. Used by BDD test infra to create a per-scenario
|
|
||||||
// repository pointing at an isolated schema (the DSN typically includes search_path=<schema>).
|
|
||||||
//
|
|
||||||
// Pass the same cfg used elsewhere (it is required by methods that read pool settings),
|
|
||||||
// but the DSN passed here OVERRIDES the host/port/dbname/etc that cfg would have built.
|
|
||||||
func NewPostgresRepositoryFromDSN(cfg *config.Config, dsn string) (*PostgresRepository, error) {
|
|
||||||
repo := &PostgresRepository{
|
|
||||||
config: cfg,
|
|
||||||
spanPrefix: "user.repo.",
|
|
||||||
}
|
|
||||||
|
|
||||||
gormLogger := logger.New(
|
|
||||||
log.New(os.Stderr, "\n", log.LstdFlags),
|
|
||||||
logger.Config{
|
|
||||||
SlowThreshold: time.Second,
|
|
||||||
LogLevel: logger.Warn,
|
|
||||||
IgnoreRecordNotFoundError: true,
|
|
||||||
Colorful: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormLogger})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect to PostgreSQL with custom DSN: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get sql.DB from gorm: %w", err)
|
|
||||||
}
|
|
||||||
sqlDB.SetMaxOpenConns(cfg.GetDatabaseMaxOpenConns())
|
|
||||||
sqlDB.SetMaxIdleConns(cfg.GetDatabaseMaxIdleConns())
|
|
||||||
sqlDB.SetConnMaxLifetime(cfg.GetDatabaseConnMaxLifetime())
|
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to auto-migrate via custom DSN: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.db = db
|
|
||||||
return repo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildSchemaIsolatedDSN returns a Postgres DSN that targets the given schema via
|
|
||||||
// the search_path connection parameter. Use with NewPostgresRepositoryFromDSN to
|
|
||||||
// get a repository whose connection only sees the per-scenario schema.
|
|
||||||
func BuildSchemaIsolatedDSN(cfg *config.Config, schemaName string) string {
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s search_path=%s",
|
|
||||||
cfg.GetDatabaseHost(),
|
|
||||||
cfg.GetDatabasePort(),
|
|
||||||
cfg.GetDatabaseUser(),
|
|
||||||
cfg.GetDatabasePassword(),
|
|
||||||
cfg.GetDatabaseName(),
|
|
||||||
cfg.GetDatabaseSSLMode(),
|
|
||||||
schemaName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec runs a raw SQL statement against the repository's connection.
|
|
||||||
// Used by BDD test infra for schema lifecycle (CREATE SCHEMA / DROP SCHEMA).
|
|
||||||
// Avoid in production code paths -- prefer the typed Repository methods.
|
|
||||||
func (r *PostgresRepository) Exec(sql string) error {
|
|
||||||
if r.db == nil {
|
|
||||||
return fmt.Errorf("Exec called on PostgresRepository with nil db")
|
|
||||||
}
|
|
||||||
return r.db.Exec(sql).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// initializeDatabase sets up the PostgreSQL database connection and runs migrations
|
// initializeDatabase sets up the PostgreSQL database connection and runs migrations
|
||||||
func (r *PostgresRepository) initializeDatabase() error {
|
func (r *PostgresRepository) initializeDatabase() error {
|
||||||
// Configure GORM logger based on config
|
// Configure GORM logger based on config
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"dance-lessons-coach/pkg/config"
|
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestNewPostgresRepositoryFromDSN_SchemaIsolation verifies that the factory
|
|
||||||
// + BuildSchemaIsolatedDSN combo produces a repository whose AutoMigrate
|
|
||||||
// creates the users table inside a per-scenario schema (NOT public).
|
|
||||||
//
|
|
||||||
// This is the foundation block for parallel-safe BDD tests (T12).
|
|
||||||
// Wiring it into the BDD testserver's SetupScenarioSchema is a follow-up.
|
|
||||||
//
|
|
||||||
// Skipped if Postgres is not available (no env vars / connection refused).
|
|
||||||
func TestNewPostgresRepositoryFromDSN_SchemaIsolation(t *testing.T) {
|
|
||||||
host := os.Getenv("DLC_DATABASE_HOST")
|
|
||||||
if host == "" {
|
|
||||||
t.Skip("DLC_DATABASE_HOST not set, skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Database.Host = host
|
|
||||||
cfg.Database.Port = parsePortOrDefault(os.Getenv("DLC_DATABASE_PORT"), 5432)
|
|
||||||
cfg.Database.User = envOr("DLC_DATABASE_USER", "postgres")
|
|
||||||
cfg.Database.Password = envOr("DLC_DATABASE_PASSWORD", "postgres")
|
|
||||||
cfg.Database.Name = envOr("DLC_DATABASE_NAME", "dance_lessons_coach_bdd_test")
|
|
||||||
cfg.Database.SSLMode = envOr("DLC_DATABASE_SSL_MODE", "disable")
|
|
||||||
|
|
||||||
schemaName := "test_isolated_dsn_factory"
|
|
||||||
|
|
||||||
// Open default repo (public schema) just to manage the schema lifecycle
|
|
||||||
defaultRepo, err := NewPostgresRepository(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("Postgres unavailable: %v", err)
|
|
||||||
}
|
|
||||||
defer defaultRepo.Close()
|
|
||||||
|
|
||||||
// Drop schema if it exists from a previous run
|
|
||||||
if err := defaultRepo.db.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName)).Error; err != nil {
|
|
||||||
t.Fatalf("DROP SCHEMA setup failed: %v", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = defaultRepo.db.Exec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schemaName)).Error
|
|
||||||
}()
|
|
||||||
|
|
||||||
// CREATE SCHEMA
|
|
||||||
if err := defaultRepo.db.Exec(fmt.Sprintf("CREATE SCHEMA %s", schemaName)).Error; err != nil {
|
|
||||||
t.Fatalf("CREATE SCHEMA failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now use the factory to open a repo whose connection has search_path=schemaName
|
|
||||||
dsn := BuildSchemaIsolatedDSN(cfg, schemaName)
|
|
||||||
isolatedRepo, err := NewPostgresRepositoryFromDSN(cfg, dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewPostgresRepositoryFromDSN failed: %v", err)
|
|
||||||
}
|
|
||||||
defer isolatedRepo.Close()
|
|
||||||
|
|
||||||
// Verify the users table now exists in our schema (not just in public)
|
|
||||||
var count int64
|
|
||||||
q := fmt.Sprintf("SELECT count(*) FROM information_schema.tables WHERE table_schema='%s' AND table_name='users'", schemaName)
|
|
||||||
if err := isolatedRepo.db.Raw(q).Scan(&count).Error; err != nil {
|
|
||||||
t.Fatalf("information_schema query failed: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Fatalf("expected users table in schema %s after AutoMigrate, count=%d", schemaName, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify a CreateUser via the isolated repo writes into the new schema, NOT public
|
|
||||||
u := &User{Username: "isolated_factory_user", PasswordHash: "x"}
|
|
||||||
if err := isolatedRepo.CreateUser(context.Background(), u); err != nil {
|
|
||||||
t.Fatalf("CreateUser via isolated repo failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var publicCount int64
|
|
||||||
if err := defaultRepo.db.Raw(fmt.Sprintf("SELECT count(*) FROM public.users WHERE username='%s'", u.Username)).Scan(&publicCount).Error; err != nil {
|
|
||||||
t.Fatalf("query public.users failed: %v", err)
|
|
||||||
}
|
|
||||||
if publicCount != 0 {
|
|
||||||
t.Fatalf("isolation leak: expected 0 rows in public.users for username=%s, got %d", u.Username, publicCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemaCount int64
|
|
||||||
if err := isolatedRepo.db.Raw(fmt.Sprintf("SELECT count(*) FROM %s.users WHERE username='%s'", schemaName, u.Username)).Scan(&schemaCount).Error; err != nil {
|
|
||||||
t.Fatalf("query schema users failed: %v", err)
|
|
||||||
}
|
|
||||||
if schemaCount != 1 {
|
|
||||||
t.Fatalf("expected 1 row in %s.users, got %d", schemaName, schemaCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// envOr returns the env var value or the fallback if empty.
|
|
||||||
func envOr(key, fallback string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePortOrDefault parses a port string or returns the fallback.
|
|
||||||
func parsePortOrDefault(s string, fallback int) int {
|
|
||||||
if s == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
var n int
|
|
||||||
_, err := fmt.Sscanf(s, "%d", &n)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
@@ -43,15 +43,6 @@ type AuthService interface {
|
|||||||
RotateJWTSecret(newSecret string)
|
RotateJWTSecret(newSecret string)
|
||||||
GetJWTSecretByIndex(index int) (string, bool)
|
GetJWTSecretByIndex(index int) (string, bool)
|
||||||
ResetJWTSecrets() // Reset JWT secrets to initial state for test cleanup
|
ResetJWTSecrets() // Reset JWT secrets to initial state for test cleanup
|
||||||
// StartJWTSecretCleanupLoop starts a goroutine that periodically calls
|
|
||||||
// RemoveExpiredJWTSecrets at the given interval, stopping when ctx is
|
|
||||||
// cancelled. Implements the cleanup half of ADR-0021. interval <= 0
|
|
||||||
// disables the loop.
|
|
||||||
StartJWTSecretCleanupLoop(ctx context.Context, interval time.Duration)
|
|
||||||
// RemoveExpiredJWTSecrets triggers an immediate cleanup pass and returns
|
|
||||||
// the count of removed non-primary expired secrets. Useful for tests
|
|
||||||
// driving cleanup synchronously.
|
|
||||||
RemoveExpiredJWTSecrets() int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserManager defines interface for user management operations
|
// UserManager defines interface for user management operations
|
||||||
|
|||||||
@@ -144,21 +144,7 @@ run_tests_with_tags() {
|
|||||||
# Note: -tags flag in go test is for Go build tags, NOT Godog feature tags
|
# Note: -tags flag in go test is for Go build tags, NOT Godog feature tags
|
||||||
# We use GODOG_TAGS env var which is read by the test framework
|
# We use GODOG_TAGS env var which is read by the test framework
|
||||||
echo "🚀 Running: GODOG_TAGS=\"${DEFAULT_TAGS}\" go test ./features/..."
|
echo "🚀 Running: GODOG_TAGS=\"${DEFAULT_TAGS}\" go test ./features/..."
|
||||||
# When BDD_SCHEMA_ISOLATION=true (T12 architecture):
|
GODOG_TAGS="$DEFAULT_TAGS" go test ./features/... -v -cover -coverpkg=./... -coverprofile=coverage.out 2>&1 | tee /tmp/bdd_test_output.txt && test_output=$(cat /tmp/bdd_test_output.txt) && rm -f /tmp/bdd_test_output.txt || test_output=$(cat /tmp/bdd_test_output.txt 2>/dev/null || echo "")
|
||||||
# each test PACKAGE gets its own isolated PostgreSQL schema with its own
|
|
||||||
# connection pool + migrations (cf. pkg/bdd/testserver/server.go Start()).
|
|
||||||
# Packages then run in parallel safely. ~2.85x speedup observed locally.
|
|
||||||
# When unset:
|
|
||||||
# fall back to -p 1 (sequential). Uses public schema with TRUNCATE-style
|
|
||||||
# cleanup between scenarios.
|
|
||||||
if [ "${BDD_SCHEMA_ISOLATION:-}" = "true" ]; then
|
|
||||||
PARALLEL_FLAG=""
|
|
||||||
echo "🔀 BDD_SCHEMA_ISOLATION=true → feature packages run in parallel"
|
|
||||||
else
|
|
||||||
PARALLEL_FLAG="-p 1"
|
|
||||||
echo "🐌 BDD_SCHEMA_ISOLATION not set → feature packages run sequentially (-p 1)"
|
|
||||||
fi
|
|
||||||
GODOG_TAGS="$DEFAULT_TAGS" go test ./features/... -v $PARALLEL_FLAG -cover -coverpkg=./... -coverprofile=coverage.out 2>&1 | tee /tmp/bdd_test_output.txt && test_output=$(cat /tmp/bdd_test_output.txt) && rm -f /tmp/bdd_test_output.txt || test_output=$(cat /tmp/bdd_test_output.txt 2>/dev/null || echo "")
|
|
||||||
test_exit_code=${PIPESTATUS[0]}
|
test_exit_code=${PIPESTATUS[0]}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user