1 Commits

Author SHA1 Message Date
e6499ac6b8 📝 docs(changelog): record PRs #67, #68, #69 2026-05-05 19:28:01 +02:00
11 changed files with 8 additions and 679 deletions

View File

@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
-`GET /api/v1/uptime` endpoint (PR #67) — returns server start_time and uptime_seconds
- 📝 mkcert local HTTPS setup + Makefile cert target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
-`pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later
## [0.1.0] - 2026-05-05
### Added

View File

@@ -1,24 +0,0 @@
# dance-lessons-coach Makefile — minimal targets for local development.
# This is a starter Makefile ; expand as needed (build, test, run, etc.).
# Existing build/test workflows live in scripts/ and remain authoritative.
CERT_DIR := ./certs
.PHONY: help cert clean-cert
help:
@echo "Available targets:"
@echo " cert Generate local-dev TLS certs via mkcert (cf. documentation/MKCERT.md)"
@echo " clean-cert Remove generated TLS certs"
@echo " help Show this help"
cert: $(CERT_DIR)
@command -v mkcert >/dev/null 2>&1 || { echo >&2 "mkcert not found. See documentation/MKCERT.md to install."; exit 1; }
mkcert -cert-file $(CERT_DIR)/dev-cert.pem -key-file $(CERT_DIR)/dev-key.pem localhost 127.0.0.1 ::1
@echo "Certs ready at $(CERT_DIR)/. Cf. documentation/MKCERT.md for usage."
$(CERT_DIR):
mkdir -p $(CERT_DIR)
clean-cert:
rm -rf $(CERT_DIR)

View File

@@ -1,120 +0,0 @@
# mkcert: Local HTTPS for Development
## Overview
This document describes how to set up local HTTPS development certificates using `mkcert`.
OIDC providers **reject `http://localhost` as a redirect URI** by default for security reasons. To test OAuth 2.0 / OpenID Connect flows locally, the development server must be accessible via HTTPS. `mkcert` provides a zero-configuration local Certificate Authority that generates trusted certificates for localhost and custom domains.
This setup is a prerequisite for **ADR-0028 Phase B** (OpenID Connect Authorization Code flow).
## Why mkcert
- **Trusted locally**: Certificates are automatically trusted by the system root store (macOS, Linux, Windows)
- **No configuration**: Single commands to create and install the CA
- **Local-only**: Certificates are valid only for localhost development, never exposed to production
- **Industry standard**: Widely adopted tool for local HTTPS development
## Installation
### macOS (Homebrew)
```bash
brew install mkcert
mkcert -install
```
The `mkcert -install` command creates and installs a local Certificate Authority in your system trust store.
### Linux
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for distribution-specific instructions.
### Windows
See [mkcert GitHub](https://github.com/FiloSottile/mkcert#installation) for Windows installation.
## Generate Certificates
Use the provided Make target to generate certificates for localhost development:
```bash
make cert
```
This runs the following command:
```bash
mkcert -cert-file ./certs/dev-cert.pem -key-file ./certs/dev-key.pem localhost 127.0.0.1 ::1
```
### Output Files
| File | Description |
|------|-------------|
| `./certs/dev-cert.pem` | TLS certificate for localhost, 127.0.0.1, and ::1 |
| `./certs/dev-key.pem` | Private key for the certificate |
Both files are created in the `./certs/` directory at the project root.
## Use in Development
Once certificates are generated, start the server with TLS enabled:
```bash
./bin/server --tls-cert ./certs/dev-cert.pem --tls-key ./certs/dev-key.pem
```
> **Note**: The `--tls-cert` and `--tls-key` flags are **not yet implemented** — this is planned for ADR-0028 Phase B.4. The Makefile and certificate generation are prepared in advance so that when the server TLS support is added, the certificates are ready.
The server will then be accessible at:
- `https://localhost:8080` (or the configured port)
- `https://127.0.0.1:8080`
- `https://[::1]:8080`
All OIDC callback URLs must use HTTPS with one of these hostnames.
## Clean Up
To remove generated certificates:
```bash
make clean-cert
```
This deletes the entire `./certs/` directory.
## .gitignore
The `certs/` directory contains locally-generated certificates and **must not be committed** to version control.
Ensure `certs/` is in your `.gitignore`. If it is not already present, add it:
```bash
echo "certs/" >> .gitignore
```
## Cross-References
- [ADR-0028: Passwordless authentication: magic link → OpenID Connect](../adr/0028-passwordless-auth-migration.md) — Phase B describes the OIDC implementation that requires HTTPS
- [mkcert GitHub Repository](https://github.com/FiloSottile/mkcert) — Official documentation
## Troubleshooting
### "mkcert not found" when running `make cert`
Ensure `mkcert` is installed and available in your `PATH`. The Makefile checks for this and will display an error message if `mkcert` is not found.
### Certificate not trusted by browser
Run `mkcert -install` again. On macOS, you may need to restart your browser completely (close all windows, not just tabs).
### Port already in use
If another process is using the port (e.g., a non-TLS server on port 8080), stop that process first or configure the server to use a different port.
## See Also
- `make help` — List all available Make targets
- [documentation/API.md](API.md) — API endpoints reference
- [documentation/BDD_GUIDE.md](BDD_GUIDE.md) — BDD testing guide

View File

@@ -1,137 +0,0 @@
# ADR-0028 Phase B Roadmap
**Document ID:** PHASE_B_ROADMAP
**Date:** 2026-05-05 evening
**Status:** In Progress
**Author:** AI Agent (vibe/batch4-task-b-phase-b-roadmap)
---
## Status as of 2026-05-05 evening
- [x] ADR-0028 Phase A complete (PRs #59-#63, #65)
- [x] Phase B.1 OIDC config (PR #64)
- [x] Phase B prep : pkg/auth skeleton (PR #69) + mkcert doc (PR #68)
- [ ] Phase B.3 not yet started
---
## Remaining work
Phase B delivers OpenID Connect Authorization Code flow with PKCE. Work is organized into **3 shippable phases**, each deliverable as an independent PR.
### Phase B.3 — OIDC client implementation
- **Goal:** Implement the core OIDC client methods in `pkg/auth/oidc.go`
- **Tasks:**
- `Discover()`: HTTP GET to `/.well-known/openid-configuration`, parse + cache discovery document
- `RefreshJWKS()`: HTTP GET to JWKS URI, parse RSA public keys, cache with TTL
- `ExchangeCode()`: POST to token endpoint with code + PKCE verifier, return TokenResponse
- `ValidateIDToken()`: Verify signature against JWKS, validate standard claims (iss, aud, exp, iat)
- **LOE:** ~200 lines of Go + unit tests
- **Dependencies:** None (uses standard library `crypto/rsa`, `encoding/jwt`)
- **Deliverable:** 1 PR
### Phase B.4 — OIDC HTTP handlers
- **Goal:** Add OIDC flow endpoints and wire them into the server
- **Tasks:**
- Create `pkg/user/api/oidc_handler.go`
- `GET /api/v1/auth/oidc/start`:
- Generate state (CSRF protection) + PKCE verifier + challenge
- Store state + verifier (cookie or short-lived in-memory store)
- Redirect to provider's authorization endpoint
- `GET /api/v1/auth/oidc/callback`:
- Validate state parameter matches stored state
- Exchange code for tokens (calls B.3 client)
- Validate id_token (calls B.3 client)
- Issue internal JWT (reuse existing JWT manager from ADR-0021)
- Return JWT in Set-Cookie + JSON body
- Wire routes in `pkg/server/server.go`
- **LOE:** ~150 lines of Go + unit tests + integration tests
- **Dependencies:** B.3 (client methods must be implemented)
- **Prerequisite:** Run `make cert` (mkcert, from PR #68) before starting dev
- **Deliverable:** 1 PR
### Phase B.5 — BDD coverage
- **Goal:** End-to-end OIDC testing
- **Tasks:**
- Create `features/auth/oidc.feature` with scenarios:
- Happy path: start → provider auth → callback → JWT issued
- Error: state mismatch
- Error: invalid code
- Error: expired id_token
- Use mock OIDC provider (local in-process) OR deterministic test against Authelia/Keycloak in docker-compose
- Follow ADR-0030 parallel BDD strategy for email assertions
- **LOE:** ~150 lines of Gherkin + step definitions
- **Dependencies:** B.3 + B.4 (endpoints must be operational)
- **Deliverable:** 1 PR
---
## Dependencies and order
```
B.3 (OIDC client)
B.4 (HTTP handlers) —— requires B.3
B.5 (BDD coverage) —— requires B.3 + B.4
```
**Note:** mkcert (PR #68) is ready. When starting B.4 development, run `make cert` once to generate local HTTPS certificates.
---
## Out of scope for Phase B (deferred)
| Item | Target Phase | Rationale |
|------|--------------|-----------|
| Decommission password auth | Phase C | Separate ADR after B is in production |
| Multi-provider (Authelia + Google) | Phase B.6 (if needed) | Single provider sufficient for MVP |
| JWKS rotation mid-flight retry | B.3 enhancement | Handle in initial implementation |
| Token refresh flow | Future | Not required for auth code flow MVP |
---
## Risk register
| Risk | Mitigation | Owner |
|------|------------|-------|
| JWKS rotation handling | Implement refresh + retry logic; key rotation must not break mid-flight validation | B.3 implementer |
| PKCE storage | Use signed cookie or short-lived in-memory store; document trade-offs in implementation PR | B.4 implementer |
| Testing without real provider | Use mock OIDC server for CI; local dev uses Authelia in docker-compose | B.5 implementer |
| State CSRF protection | Use cryptographically random state; store server-side with short TTL | B.4 implementer |
---
## Cross-references
- [ADR-0028: Passwordless authentication: magic link → OpenID Connect](../adr/0028-passwordless-auth-migration.md)
- [ADR-0029: Email infrastructure (Mailpit)](../adr/0029-email-infrastructure-mailpit.md)
- [ADR-0030: BDD email parallel strategy](../adr/0030-bdd-email-parallel-strategy.md)
- [PR #59: Email infrastructure](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/59)
- [PR #60: BDD Mailpit helper](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/60)
- [PR #61: magic_link_tokens table](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/61)
- [PR #62: Magic link handlers](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/62)
- [PR #63: Magic link BDD](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/63)
- [PR #64: OIDC config skeleton](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/64)
- [PR #65: Magic link cleanup loop](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/65)
- [PR #68: mkcert documentation](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/68)
- [PR #69: pkg/auth skeleton](https://gitea.arcodange.lab/arcodange/dance-lessons-coach/pulls/69)
---
## Appendix: File inventory
Existing (merged):
- `pkg/auth/oidc.go` — skeleton with TODO methods (PR #69)
- `pkg/auth/oidc_test.go` — placeholder tests (PR #69)
- `documentation/MKCERT.md` — mkcert setup guide (PR #68)
- `Makefile` — includes `make cert` target (PR #68)
To be created:
- `pkg/auth/oidc.go` — complete implementation (B.3)
- `pkg/user/api/oidc_handler.go` — HTTP handlers (B.4)
- `pkg/server/server.go` — route wiring (B.4)
- `features/auth/oidc.feature` — BDD scenarios (B.5)
- `pkg/auth/oidc_test.go` — expanded unit tests (B.3)
- `pkg/user/api/oidc_handler_test.go` — handler tests (B.4)

View File

@@ -1,104 +0,0 @@
// Package auth provides OpenID Connect client primitives for the
// dance-lessons-coach passwordless-auth migration (ADR-0028 Phase B).
//
// This file defines the client surface only. HTTP handlers wire-up
// happens in pkg/user/api/oidc_handler.go (separate phase B.3).
package auth
import (
"context"
"crypto/rsa"
"net/http"
"sync"
"time"
)
// OIDCClient is a per-provider OIDC client.
// Holds the discovery document + JWKS cache + OAuth code-exchange config.
type OIDCClient struct {
issuerURL string
clientID string
clientSecret string
httpClient *http.Client
// discovery document, lazy-fetched on first use
discoveryMu sync.RWMutex
discovery *Discovery
// JWKS cache (id_token signature verification keys), refreshed periodically
jwksMu sync.RWMutex
jwks map[string]*rsa.PublicKey
jwksFetched time.Time
}
// Discovery is the subset of the .well-known/openid-configuration document we use.
type Discovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
JWKSUri string `json:"jwks_uri"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
}
// TokenResponse is the response from the token endpoint after code exchange.
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token"`
Scope string `json:"scope,omitempty"`
}
// IDTokenClaims represents the parsed claims from an ID token.
type IDTokenClaims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
ExpirationTime int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Nonce string `json:"nonce,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
}
// NewOIDCClient constructs a client. Discovery + JWKS are NOT fetched eagerly;
// they are lazy-loaded on first use to avoid blocking server startup if the
// provider is temporarily down.
func NewOIDCClient(issuerURL, clientID, clientSecret string) *OIDCClient {
return &OIDCClient{
issuerURL: issuerURL,
clientID: clientID,
clientSecret: clientSecret,
httpClient: &http.Client{Timeout: 10 * time.Second},
jwks: make(map[string]*rsa.PublicKey),
}
}
// Discover fetches and caches the .well-known document. Idempotent.
// First call: HTTP fetch + cache. Subsequent calls: cached value.
func (c *OIDCClient) Discover(ctx context.Context) (*Discovery, error) {
// TODO Phase B.3: implement (HTTP GET issuerURL + "/.well-known/openid-configuration")
return nil, nil // placeholder for skeleton phase
}
// RefreshJWKS fetches JWKS URI, parse keys, populate jwks map.
// TODO Phase B.3: implement
func (c *OIDCClient) RefreshJWKS(ctx context.Context) error {
// TODO Phase B.3: implement (HTTP GET to JWKS URI from discovery, parse keys)
return nil // placeholder for skeleton phase
}
// ExchangeCode exchanges an authorization code for an access token and ID token.
// TODO Phase B.3: implement
func (c *OIDCClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI string) (*TokenResponse, error) {
// TODO Phase B.3: implement (POST to token_endpoint with code, code_verifier, redirect_uri)
return nil, nil // placeholder for skeleton phase
}
// ValidateIDToken verifies the signature and claims of an ID token.
// TODO Phase B.3: implement
func (c *OIDCClient) ValidateIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) {
// TODO Phase B.3: implement (verify signature with JWKS, validate claims)
return nil, nil // placeholder for skeleton phase
}

View File

@@ -1,13 +0,0 @@
package auth
import "testing"
func TestNewOIDCClient(t *testing.T) {
c := NewOIDCClient("https://example.com", "client_id", "client_secret")
if c == nil {
t.Fatal("NewOIDCClient returned nil")
}
if c.issuerURL != "https://example.com" {
t.Errorf("issuerURL not set: got %q", c.issuerURL)
}
}

View File

@@ -109,27 +109,12 @@ type AuthConfig struct {
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
type MagicLinkConfig struct {
TTL time.Duration `mapstructure:"ttl"`
BaseURL string `mapstructure:"base_url"`
CleanupInterval time.Duration `mapstructure:"cleanup_interval"`
}
// OIDCConfig holds OpenID Connect provider configuration (ADR-0028 Phase B).
// Multiple providers are supported via a map keyed by provider name (e.g. "arcodange-sso", "google").
type OIDCConfig struct {
Providers map[string]OIDCProvider `mapstructure:"providers"`
}
// OIDCProvider describes a single OIDC provider's discovery + client config.
type OIDCProvider struct {
IssuerURL string `mapstructure:"issuer_url"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
TTL time.Duration `mapstructure:"ttl"`
BaseURL string `mapstructure:"base_url"`
}
// EmailConfig holds outgoing email transport configuration.
@@ -301,11 +286,6 @@ func LoadConfig() (*Config, error) {
// Magic-link defaults (ADR-0028 Phase A).
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
v.SetDefault("auth.magic_link.cleanup_interval", 1*time.Hour)
// OIDC defaults (ADR-0028 Phase B). Providers map is empty by default;
// configured per environment via config file or env vars.
v.SetDefault("auth.oidc.providers", map[string]interface{}{})
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
@@ -363,14 +343,6 @@ func LoadConfig() (*Config, error) {
// Magic-link environment variables (ADR-0028 Phase A).
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
v.BindEnv("auth.magic_link.base_url", "DLC_AUTH_MAGIC_LINK_BASE_URL")
v.BindEnv("auth.magic_link.cleanup_interval", "DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL")
// OIDC environment variables (ADR-0028 Phase B). One canonical "default"
// provider is bindable via env; additional providers must be defined in config.yaml.
v.BindEnv("auth.oidc.providers.default.issuer_url", "DLC_AUTH_OIDC_ISSUER_URL")
v.BindEnv("auth.oidc.providers.default.client_id", "DLC_AUTH_OIDC_CLIENT_ID")
v.BindEnv("auth.oidc.providers.default.client_secret", "DLC_AUTH_OIDC_CLIENT_SECRET")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
@@ -522,23 +494,6 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
return out
}
// GetOIDCProviders returns the configured OIDC providers, keyed by provider name.
// Empty map (not nil) is returned when no providers are configured.
func (c *Config) GetOIDCProviders() map[string]OIDCProvider {
if c.Auth.OIDC.Providers == nil {
return map[string]OIDCProvider{}
}
return c.Auth.OIDC.Providers
}
// GetMagicLinkCleanupInterval returns the magic-link cleanup interval (ADR-0028 Phase A consequence).
func (c *Config) GetMagicLinkCleanupInterval() time.Duration {
if c.Auth.MagicLink.CleanupInterval <= 0 {
return 1 * time.Hour
}
return c.Auth.MagicLink.CleanupInterval
}
// GetJWTTTL returns the JWT TTL
func (c *Config) GetJWTTTL() time.Duration {
if c.Auth.JWT.TTL == 0 {

View File

@@ -246,9 +246,6 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
r.Get("/{name}", s.handleGreetPath)
})
// Uptime endpoint
r.Get("/uptime", s.handleUptime)
// Register user authentication routes
if s.userService != nil && s.userRepo != nil {
// Use unified user service - much simpler!
@@ -586,30 +583,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
w.Write(data)
}
// UptimeResponse represents the JSON response for /api/v1/uptime
type UptimeResponse struct {
StartTime string `json:"start_time"`
UptimeSeconds int `json:"uptime_seconds"`
}
// handleUptime godoc
//
// @Summary Get server uptime
// @Description Returns server start time and uptime duration
// @Tags System/Info
// @Produce json
// @Success 200 {object} UptimeResponse
// @Router /v1/uptime [get]
func (s *Server) handleUptime(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Uptime check requested")
resp := UptimeResponse{
StartTime: s.startedAt.Format(time.RFC3339),
UptimeSeconds: int(time.Since(s.startedAt).Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// handleGreetQuery godoc
//
// @Summary Get greeting with cache
@@ -791,12 +764,6 @@ func (s *Server) Run() error {
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
}
// Start the magic-link expired-token cleanup loop (ADR-0028 Phase A consequence).
if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok {
runner := user.NewMagicLinkCleanupRunner(mlRepo)
runner.StartCleanupLoop(rootCtx, s.config.GetMagicLinkCleanupInterval())
}
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
// telemetrySetup is non-nil only when telemetry was successfully initialized
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).

View File

@@ -1,81 +0,0 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
)
func TestHandleUptime(t *testing.T) {
// Setup with a known start time
cfg := &config.Config{}
// We need to create a server and then set its startedAt to a known time
// Since NewServer sets startedAt to time.Now(), we'll create the server
// and then use reflection or we can use NewServerWithUserRepo which also sets startedAt
s := NewServer(cfg, context.Background())
// Set a fixed start time for deterministic testing
// We can't directly set s.startedAt since it's unexported, but we can test
// that the handler uses the server's startedAt
// The test will verify the structure and that uptime_seconds is >= 0
// Create request
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
// Call handler
s.handleUptime(w, req)
// Check status code
assert.Equal(t, http.StatusOK, w.Code)
// Check content type
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
// Decode response
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Assert fields
assert.NotEmpty(t, resp.StartTime)
// Verify start_time is in RFC3339 format
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
}
func TestHandleUptime_Deterministic(t *testing.T) {
// For a more deterministic test, we would need to be able to set startedAt
// Since startedAt is unexported, we test the behavior with a known server
// that was just created (uptime should be very small)
cfg := &config.Config{}
s := NewServer(cfg, context.Background())
// Small delay to ensure uptime is at least 0 seconds
time.Sleep(10 * time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/api/v1/uptime", nil)
w := httptest.NewRecorder()
s.handleUptime(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp UptimeResponse
err := json.NewDecoder(w.Body).Decode(&resp)
assert.NoError(t, err)
// Uptime should be at least 0 (it's int() of seconds, so minimum is 0)
assert.GreaterOrEqual(t, resp.UptimeSeconds, 0)
// Start time should be parseable
_, err = time.Parse(time.RFC3339, resp.StartTime)
assert.NoError(t, err)
}

View File

@@ -1,56 +0,0 @@
package user
import (
"context"
"time"
"github.com/rs/zerolog/log"
)
// MagicLinkCleanupRunner periodically deletes expired magic-link tokens
// (ADR-0028 Phase A consequence — the rows accumulate without cleanup
// otherwise, and stale rows are pure overhead since the token plaintext
// is never stored).
type MagicLinkCleanupRunner struct {
repo MagicLinkRepository
}
// NewMagicLinkCleanupRunner creates a new cleanup runner.
func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner {
return &MagicLinkCleanupRunner{repo: repo}
}
// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx
// is cancelled. interval <= 0 disables the loop.
func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _ = r.runOnce(ctx)
}
}
}()
}
// runOnce performs a single cleanup pass. Returns the count of deleted rows.
// Exposed for testing — tests drive runOnce directly instead of waiting on
// the ticker.
func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) {
n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now())
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed")
return 0, err
}
if n > 0 {
log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens")
}
return n, nil
}

View File

@@ -1,64 +0,0 @@
package user
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeMLRepo struct {
deleteN int64
deleteErr error
cutoffSeen time.Time
}
func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil }
func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) {
return nil, nil
}
func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error {
return nil
}
func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) {
r.cutoffSeen = before
return r.deleteN, r.deleteErr
}
func TestRunOnce_ReturnsCount(t *testing.T) {
repo := &fakeMLRepo{deleteN: 7}
r := NewMagicLinkCleanupRunner(repo)
n, err := r.runOnce(context.Background())
require.NoError(t, err)
assert.EqualValues(t, 7, n)
assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second)
}
func TestRunOnce_PropagatesError(t *testing.T) {
repo := &fakeMLRepo{deleteErr: errors.New("simulated")}
r := NewMagicLinkCleanupRunner(repo)
_, err := r.runOnce(context.Background())
require.Error(t, err)
}
func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
ctx, cancel := context.WithCancel(context.Background())
r.StartCleanupLoop(ctx, 10*time.Millisecond)
time.Sleep(25 * time.Millisecond) // 2 ticks
cancel()
time.Sleep(15 * time.Millisecond) // give the goroutine time to exit
// Implicit assertion: no goroutine leak (test would hang in -race mode otherwise).
}
func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) {
repo := &fakeMLRepo{}
r := NewMagicLinkCleanupRunner(repo)
r.StartCleanupLoop(context.Background(), 0)
// Just make sure no goroutine is started ; nothing observable to assert
// beyond "no panic, returns immediately".
}