Compare commits
1 Commits
b17b727157
...
vibe/batch
| Author | SHA1 | Date | |
|---|---|---|---|
| e6499ac6b8 |
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.0] - 2026-05-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -109,27 +109,12 @@ type AuthConfig struct {
|
|||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
Email EmailConfig `mapstructure:"email"`
|
Email EmailConfig `mapstructure:"email"`
|
||||||
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
|
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
|
||||||
OIDC OIDCConfig `mapstructure:"oidc"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
|
// MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A).
|
||||||
type MagicLinkConfig struct {
|
type MagicLinkConfig struct {
|
||||||
TTL time.Duration `mapstructure:"ttl"`
|
TTL time.Duration `mapstructure:"ttl"`
|
||||||
BaseURL string `mapstructure:"base_url"`
|
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailConfig holds outgoing email transport configuration.
|
// EmailConfig holds outgoing email transport configuration.
|
||||||
@@ -301,11 +286,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
// Magic-link defaults (ADR-0028 Phase A).
|
// Magic-link defaults (ADR-0028 Phase A).
|
||||||
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
|
v.SetDefault("auth.magic_link.ttl", 15*time.Minute)
|
||||||
v.SetDefault("auth.magic_link.base_url", "http://localhost:8080")
|
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
|
// Check for custom config file path via environment variable
|
||||||
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
||||||
@@ -363,14 +343,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
// Magic-link environment variables (ADR-0028 Phase A).
|
// Magic-link environment variables (ADR-0028 Phase A).
|
||||||
v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL")
|
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.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.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
||||||
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
||||||
|
|
||||||
@@ -522,23 +494,6 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
|
|||||||
return out
|
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
|
// GetJWTTTL returns the JWT TTL
|
||||||
func (c *Config) GetJWTTTL() time.Duration {
|
func (c *Config) GetJWTTTL() time.Duration {
|
||||||
if c.Auth.JWT.TTL == 0 {
|
if c.Auth.JWT.TTL == 0 {
|
||||||
|
|||||||
@@ -246,9 +246,6 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
|
|||||||
r.Get("/{name}", s.handleGreetPath)
|
r.Get("/{name}", s.handleGreetPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Uptime endpoint
|
|
||||||
r.Get("/uptime", s.handleUptime)
|
|
||||||
|
|
||||||
// Register user authentication routes
|
// Register user authentication routes
|
||||||
if s.userService != nil && s.userRepo != nil {
|
if s.userService != nil && s.userRepo != nil {
|
||||||
// Use unified user service - much simpler!
|
// Use unified user service - much simpler!
|
||||||
@@ -586,30 +583,6 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(data)
|
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
|
// handleGreetQuery godoc
|
||||||
//
|
//
|
||||||
// @Summary Get greeting with cache
|
// @Summary Get greeting with cache
|
||||||
@@ -791,12 +764,6 @@ func (s *Server) Run() error {
|
|||||||
s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval())
|
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).
|
// Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3).
|
||||||
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
// telemetrySetup is non-nil only when telemetry was successfully initialized
|
||||||
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
// at startup — hot-reloading telemetry-on is out of scope (see ADR-0023).
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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".
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user