feat: scaffold app skeleton with config, ULID, health endpoints, compose stack (phase 1)
- internal .env loader (real env wins) + strictly validated config struct - ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable - HTTP server with recovery/request-id/trusted-proxy/access-log middleware - /healthz, /readyz (pluggable checkers), /metricsz (counter registry) - multi-stage Dockerfile (alpine, non-root) + compose with healthchecks, app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReadinessCheck is a named dependency probe run by /readyz. Required checks
|
||||
// (Mongo) gate readiness; optional ones (atomizer, work performer) are
|
||||
// reported but non-fatal, per §6.
|
||||
type ReadinessCheck struct {
|
||||
Name string
|
||||
Required bool
|
||||
Probe func(ctx context.Context) error
|
||||
}
|
||||
|
||||
// AddReadinessCheck registers a probe; later phases plug in Mongo and the
|
||||
// external services here.
|
||||
func (s *Server) AddReadinessCheck(c ReadinessCheck) {
|
||||
s.checks = append(s.checks, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type checkResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Required bool `json:"required"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "ok"
|
||||
httpStatus := http.StatusOK
|
||||
results := make(map[string]checkResult, len(s.checks))
|
||||
for _, c := range s.checks {
|
||||
res := checkResult{OK: true, Required: c.Required}
|
||||
if err := c.Probe(ctx); err != nil {
|
||||
res.OK = false
|
||||
res.Error = err.Error()
|
||||
if c.Required {
|
||||
status = "unavailable"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else if status == "ok" {
|
||||
status = "degraded"
|
||||
}
|
||||
}
|
||||
results[c.Name] = res
|
||||
}
|
||||
writeJSON(w, httpStatus, map[string]any{"status": status, "checks": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleMetricsz(w http.ResponseWriter, _ *http.Request) {
|
||||
counters, gauges := s.metrics.Snapshot()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"uptimeSec": int64(time.Since(s.startedAt).Seconds()),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"counters": counters,
|
||||
"gauges": gauges,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
ctxKeyRequestID ctxKey = iota
|
||||
ctxKeyClient
|
||||
)
|
||||
|
||||
// RequestID returns the id assigned to this request by the middleware chain.
|
||||
func RequestID(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxKeyRequestID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// Client returns the trusted-proxy-resolved client info for this request.
|
||||
func Client(ctx context.Context) clientInfo {
|
||||
c, _ := ctx.Value(ctxKeyClient).(clientInfo)
|
||||
return c
|
||||
}
|
||||
|
||||
// ClientIP is a convenience accessor used by rate limiting and audit logging.
|
||||
func ClientIP(ctx context.Context) netip.Addr { return Client(ctx).IP }
|
||||
|
||||
// statusRecorder captures the response status for logging and metrics while
|
||||
// staying compatible with http.ResponseController (Unwrap).
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
if r.status == 0 {
|
||||
r.status = code
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
|
||||
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return s.recoverPanic(s.assignRequestID(s.resolveClientInfo(s.accessLog(next))))
|
||||
}
|
||||
|
||||
// recoverPanic guarantees no panic escapes a request path: it logs the stack
|
||||
// and returns the standard JSON error envelope.
|
||||
func (s *Server) recoverPanic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
if rec == http.ErrAbortHandler { // client went away mid-write
|
||||
panic(rec)
|
||||
}
|
||||
s.log.Error("panic in request handler",
|
||||
"err", fmt.Sprint(rec),
|
||||
"requestId", RequestID(r.Context()),
|
||||
"path", r.URL.Path,
|
||||
"stack", string(debug.Stack()))
|
||||
s.metrics.Inc("http_panics_total", 1)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) assignRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := ulid.New()
|
||||
w.Header().Set("X-Request-Id", id)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) resolveClientInfo(next http.Handler) http.Handler {
|
||||
defaultProto := "http"
|
||||
if r := s.cfg.AppBaseURL; len(r) >= 8 && r[:8] == "https://" {
|
||||
defaultProto = "https"
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
info := resolveClient(r, s.cfg.TrustedProxyCIDRs, defaultProto)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyClient, info)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) accessLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
next.ServeHTTP(rec, r)
|
||||
if rec.status == 0 {
|
||||
rec.status = http.StatusOK
|
||||
}
|
||||
s.metrics.Inc("http_requests_total", 1)
|
||||
s.metrics.Inc(fmt.Sprintf("http_responses_%dxx_total", rec.status/100), 1)
|
||||
s.log.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"durationMs", time.Since(start).Milliseconds(),
|
||||
"clientIp", Client(r.Context()).IP.String(),
|
||||
"requestId", RequestID(r.Context()))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// clientInfo is the resolved view of the requester after applying the
|
||||
// trusted-proxy policy. It feeds rate limiting, sessions, and the audit log.
|
||||
type clientInfo struct {
|
||||
IP netip.Addr
|
||||
Proto string // effective scheme: "http" or "https"
|
||||
Host string // effective host header
|
||||
}
|
||||
|
||||
func parsePeer(remoteAddr string) netip.Addr {
|
||||
if ap, err := netip.ParseAddrPort(remoteAddr); err == nil {
|
||||
return ap.Addr().Unmap()
|
||||
}
|
||||
if a, err := netip.ParseAddr(remoteAddr); err == nil {
|
||||
return a.Unmap()
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
|
||||
if !a.IsValid() {
|
||||
return false
|
||||
}
|
||||
for _, p := range trusted {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveClient applies §12: X-Forwarded-For / X-Forwarded-Proto /
|
||||
// X-Forwarded-Host are honored only when the direct peer is inside
|
||||
// TRUSTED_PROXY_CIDRS; otherwise the headers are ignored entirely.
|
||||
//
|
||||
// The client IP is found by walking the X-Forwarded-For chain from the
|
||||
// rightmost entry (appended by our own proxy) towards the left, skipping
|
||||
// addresses that are themselves trusted proxies; the first untrusted address
|
||||
// is the real client. A malformed entry stops the walk (everything further
|
||||
// left is attacker-controllable).
|
||||
func resolveClient(r *http.Request, trusted []netip.Prefix, defaultProto string) clientInfo {
|
||||
peer := parsePeer(r.RemoteAddr)
|
||||
info := clientInfo{IP: peer, Proto: defaultProto, Host: r.Host}
|
||||
if len(trusted) == 0 || !isTrusted(peer, trusted) {
|
||||
return info
|
||||
}
|
||||
|
||||
var chain []string
|
||||
for _, h := range r.Header.Values("X-Forwarded-For") {
|
||||
for part := range strings.SplitSeq(h, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
chain = append(chain, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
a := parsePeer(chain[i])
|
||||
if !a.IsValid() {
|
||||
break
|
||||
}
|
||||
info.IP = a
|
||||
if !isTrusted(a, trusted) {
|
||||
break
|
||||
}
|
||||
// a is itself a trusted proxy: keep walking left. If the whole chain
|
||||
// is trusted, the leftmost entry stands as the best available answer.
|
||||
}
|
||||
|
||||
switch p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p {
|
||||
case "http", "https":
|
||||
info.Proto = p
|
||||
}
|
||||
if h := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); h != "" {
|
||||
info.Host = h
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func prefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||
t.Helper()
|
||||
var out []netip.Prefix
|
||||
for _, c := range cidrs {
|
||||
p, err := netip.ParsePrefix(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestResolveClient(t *testing.T) {
|
||||
trusted := "172.16.0.0/12,127.0.0.0/8"
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
cidrs string
|
||||
hdr map[string][]string
|
||||
wantIP string
|
||||
wantProto string
|
||||
wantHost string // empty = request Host
|
||||
}{
|
||||
{
|
||||
name: "no proxies configured: headers ignored",
|
||||
remoteAddr: "203.0.113.7:4444",
|
||||
cidrs: "",
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}},
|
||||
wantIP: "203.0.113.7",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "untrusted peer spoofing headers: ignored",
|
||||
remoteAddr: "203.0.113.7:4444",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"evil.example"}},
|
||||
wantIP: "203.0.113.7",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "trusted peer, single hop",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"https"}, "X-Forwarded-Host": {"bounty.example.com"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "https",
|
||||
wantHost: "bounty.example.com",
|
||||
},
|
||||
{
|
||||
name: "client-spoofed XFF prefix is skipped (rightmost untrusted wins)",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"6.6.6.6, 198.51.100.1"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "chain of trusted proxies collapses to real client",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1, 172.20.0.5, 172.20.0.9"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "all-trusted chain falls back to leftmost",
|
||||
remoteAddr: "127.0.0.1:9999",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"172.20.0.5"}},
|
||||
wantIP: "172.20.0.5",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "trusted peer without forwarded headers",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: nil,
|
||||
wantIP: "172.18.0.2",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "malformed XFF entry stops the walk",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"garbage, 198.51.100.1"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "multiple XFF headers are concatenated in order",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1", "172.20.0.5"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "invalid forwarded proto ignored",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"198.51.100.1"}, "X-Forwarded-Proto": {"gopher"}},
|
||||
wantIP: "198.51.100.1",
|
||||
wantProto: "http",
|
||||
},
|
||||
{
|
||||
name: "ipv6 client through trusted proxy",
|
||||
remoteAddr: "172.18.0.2:1234",
|
||||
cidrs: trusted,
|
||||
hdr: map[string][]string{"X-Forwarded-For": {"2001:db8::1"}},
|
||||
wantIP: "2001:db8::1",
|
||||
wantProto: "http",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "http://app.local/", nil)
|
||||
r.RemoteAddr = tt.remoteAddr
|
||||
for k, vs := range tt.hdr {
|
||||
for _, v := range vs {
|
||||
r.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
var cidrs []netip.Prefix
|
||||
if tt.cidrs != "" {
|
||||
cidrs = prefixes(t, "172.16.0.0/12", "127.0.0.0/8")
|
||||
}
|
||||
got := resolveClient(r, cidrs, "http")
|
||||
if got.IP.String() != tt.wantIP {
|
||||
t.Errorf("IP = %s, want %s", got.IP, tt.wantIP)
|
||||
}
|
||||
if got.Proto != tt.wantProto {
|
||||
t.Errorf("Proto = %s, want %s", got.Proto, tt.wantProto)
|
||||
}
|
||||
wantHost := tt.wantHost
|
||||
if wantHost == "" {
|
||||
wantHost = "app.local"
|
||||
}
|
||||
if got.Host != wantHost {
|
||||
t.Errorf("Host = %s, want %s", got.Host, wantHost)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package httpx implements the application HTTP server: routing, middleware
|
||||
// (recovery, request id, trusted-proxy client resolution, access logging),
|
||||
// health endpoints, and shared JSON response helpers. Named httpx to avoid
|
||||
// clashing with net/http; it lives in internal/http per the repository layout.
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// errorEnvelope is the consistent error body for every JSON error response:
|
||||
// {"error":{"code":"...","message":"..."}}.
|
||||
type errorEnvelope struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write json response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/metrics"
|
||||
)
|
||||
|
||||
// shutdownBudget is the §12 graceful-shutdown drain window.
|
||||
const shutdownBudget = 15 * time.Second
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
metrics *metrics.Registry
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
httpSrv *http.Server
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
metrics: reg,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.AppPort),
|
||||
Handler: s.withMiddleware(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler exposes the full middleware-wrapped handler for tests.
|
||||
func (s *Server) Handler() http.Handler { return s.httpSrv.Handler }
|
||||
|
||||
// Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight
|
||||
// requests within the shutdown budget.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL)
|
||||
if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
s.log.Info("shutting down", "budget", shutdownBudget.String())
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget)
|
||||
defer cancel()
|
||||
if err := s.httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("graceful shutdown: %w", err)
|
||||
}
|
||||
return <-errCh
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/metrics"
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
|
||||
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, log, metrics.NewRegistry())
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
rec := get(t, s.Handler(), "/healthz")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status field = %q", body["status"])
|
||||
}
|
||||
if id := rec.Header().Get("X-Request-Id"); !ulid.IsValid(id) {
|
||||
t.Errorf("X-Request-Id %q is not a ULID", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzAggregation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checks []ReadinessCheck
|
||||
wantStatus string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "no checks",
|
||||
wantStatus: "ok",
|
||||
wantCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "all passing",
|
||||
checks: []ReadinessCheck{
|
||||
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
|
||||
{Name: "atomizer", Probe: func(context.Context) error { return nil }},
|
||||
},
|
||||
wantStatus: "ok",
|
||||
wantCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "optional failing is non-fatal",
|
||||
checks: []ReadinessCheck{
|
||||
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
|
||||
{Name: "atomizer", Probe: func(context.Context) error { return errors.New("down") }},
|
||||
},
|
||||
wantStatus: "degraded",
|
||||
wantCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "required failing gates readiness",
|
||||
checks: []ReadinessCheck{
|
||||
{Name: "mongo", Required: true, Probe: func(context.Context) error { return errors.New("no reachable servers") }},
|
||||
},
|
||||
wantStatus: "unavailable",
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
for _, c := range tt.checks {
|
||||
s.AddReadinessCheck(c)
|
||||
}
|
||||
rec := get(t, s.Handler(), "/readyz")
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Fatalf("code = %d, want %d", rec.Code, tt.wantCode)
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Checks map[string]checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Status != tt.wantStatus {
|
||||
t.Errorf("status = %q, want %q", body.Status, tt.wantStatus)
|
||||
}
|
||||
if len(body.Checks) != len(tt.checks) {
|
||||
t.Errorf("checks = %d, want %d", len(body.Checks), len(tt.checks))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricszCountsRequests(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
get(t, s.Handler(), "/healthz")
|
||||
get(t, s.Handler(), "/healthz")
|
||||
rec := get(t, s.Handler(), "/metricsz")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Counters map[string]int64 `json:"counters"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Counters["http_requests_total"] != 2 {
|
||||
t.Errorf("http_requests_total = %d, want 2", body.Counters["http_requests_total"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanicRecoveryEnvelope(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /boom", func(http.ResponseWriter, *http.Request) { panic("kaboom") })
|
||||
h := s.withMiddleware(mux)
|
||||
|
||||
rec := get(t, h, "/boom")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env.Error.Code != "internal" {
|
||||
t.Errorf("error code = %q", env.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownRouteNotFound(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
rec := get(t, s.Handler(), "/nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user