Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type stubPinger struct{ err error }
|
||||
|
||||
func (p stubPinger) Ping(context.Context) error { return p.err }
|
||||
|
||||
type stubHBRow struct {
|
||||
scan func(dest ...any) error
|
||||
}
|
||||
|
||||
func (r stubHBRow) Scan(dest ...any) error {
|
||||
if r.scan == nil {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return r.scan(dest...)
|
||||
}
|
||||
|
||||
type stubHeartbeat struct {
|
||||
pending int64
|
||||
lastSeen time.Time
|
||||
seenErr error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (q *stubHeartbeat) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
q.calls++
|
||||
if q.calls == 1 {
|
||||
return stubHBRow{scan: func(dest ...any) error {
|
||||
*(dest[0].(*int64)) = q.pending
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
return stubHBRow{scan: func(dest ...any) error {
|
||||
if q.seenErr != nil {
|
||||
return q.seenErr
|
||||
}
|
||||
*(dest[0].(*time.Time)) = q.lastSeen
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
func liveWorkerProbe() jobs.HeartbeatQuerier {
|
||||
return &stubHeartbeat{pending: 2, lastSeen: time.Now()}
|
||||
}
|
||||
|
||||
func TestHandleHealthzOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true, HypercareMode: true}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleHealthz(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["service"] != healthServiceName {
|
||||
t.Fatalf("service = %#v", body["service"])
|
||||
}
|
||||
if body["maintenance"] != true || body["read_only"] != true || body["hypercare"] != true {
|
||||
t.Fatalf("expected maintenance/read_only/hypercare flags, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReadyzNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}, Pool: nil}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "not_ready" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["service"] != healthServiceName {
|
||||
t.Fatalf("service = %#v", body["service"])
|
||||
}
|
||||
if body["maintenance"] != true || body["read_only"] != true {
|
||||
t.Fatalf("expected flags on 503, got %#v", body)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if checks["database"] != "unavailable" {
|
||||
t.Fatalf("checks = %#v", body["checks"])
|
||||
}
|
||||
if body["error"] != "database pool unavailable" {
|
||||
t.Fatalf("error = %#v", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzPingOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{ReadOnlyMode: true}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, liveWorkerProbe())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ready" || body["service"] != healthServiceName {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Fatalf("read_only = %#v", body["read_only"])
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if checks["database"] != "ok" || checks["worker"] != "ok" || checks["queue"] != "ok" {
|
||||
t.Fatalf("checks = %#v", body["checks"])
|
||||
}
|
||||
if body["queue_pending"] != float64(2) {
|
||||
t.Fatalf("queue_pending = %#v", body["queue_pending"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzWorkerStale(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
rec := httptest.NewRecorder()
|
||||
stale := &stubHeartbeat{pending: 5, lastSeen: time.Now().Add(-2 * time.Minute)}
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, stale)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if body["status"] != "not_ready" || checks["worker"] != "stale" || checks["database"] != "ok" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["queue_pending"] != float64(5) {
|
||||
t.Fatalf("queue_pending = %#v", body["queue_pending"])
|
||||
}
|
||||
if body["error"] != "worker heartbeat stale" {
|
||||
t.Fatalf("error = %#v", body["error"])
|
||||
}
|
||||
reason, _ := body["reason"].(string)
|
||||
if reason == "" || !strings.Contains(reason, "npm run dev") {
|
||||
t.Fatalf("reason = %#v", body["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzPingFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{err: errors.New("boom")}, liveWorkerProbe())
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if body["status"] != "not_ready" || checks["database"] != "fail" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["error"] != "database ping failed" {
|
||||
t.Fatalf("error leaked detail: %#v", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
ok, check, msg := databaseReady(ctx, nil)
|
||||
if ok || check != "unavailable" || msg == "" {
|
||||
t.Fatalf("nil pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
ok, check, msg = databaseReady(ctx, stubPinger{err: errors.New("x")})
|
||||
if ok || check != "fail" || msg != "database ping failed" {
|
||||
t.Fatalf("fail pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
ok, check, msg = databaseReady(ctx, stubPinger{})
|
||||
if !ok || check != "ok" || msg != "" {
|
||||
t.Fatalf("ok pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceGateBlocksNonHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
blocked := httptest.NewRecorder()
|
||||
h.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if blocked.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("blocked status = %d", blocked.Code)
|
||||
}
|
||||
assertGateBody(t, blocked, "maintenance", true, false)
|
||||
|
||||
ok := httptest.NewRecorder()
|
||||
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("health status = %d", ok.Code)
|
||||
}
|
||||
|
||||
ready := httptest.NewRecorder()
|
||||
h.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if ready.Code != http.StatusOK {
|
||||
t.Fatalf("readyz status = %d", ready.Code)
|
||||
}
|
||||
|
||||
// Query string must not defeat the probe exemption (Path is still /healthz).
|
||||
probeQ := httptest.NewRecorder()
|
||||
h.ServeHTTP(probeQ, httptest.NewRequest(http.MethodGet, "/healthz?ping=1", nil))
|
||||
if probeQ.Code != http.StatusOK {
|
||||
t.Fatalf("healthz?query status = %d", probeQ.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyGateBlocksMutations(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{ReadOnlyMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200", method, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("%s status = %d, want 503", method, rec.Code)
|
||||
}
|
||||
assertGateBody(t, rec, "read_only", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceGatePrecedenceOverReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
getRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(getRec, httptest.NewRequest(http.MethodGet, "/api/products", nil))
|
||||
if getRec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("GET status = %d, want 503", getRec.Code)
|
||||
}
|
||||
assertGateBody(t, getRec, "maintenance", true, true)
|
||||
|
||||
postRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(postRec, httptest.NewRequest(http.MethodPost, "/api/products", nil))
|
||||
if postRec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("POST status = %d, want 503", postRec.Code)
|
||||
}
|
||||
assertGateBody(t, postRec, "maintenance", true, true)
|
||||
|
||||
ok := httptest.NewRecorder()
|
||||
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("readyz status = %d", ok.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMaintenanceAndReadOnlyBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
maint := testAPIServer()
|
||||
maint.Config.MaintenanceMode = true
|
||||
maintH := maint.Router()
|
||||
|
||||
maintPOST := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(maintPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
|
||||
if maintPOST.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("maintenance POST without CSRF status = %d, want 503 (not csrf 403); body=%s", maintPOST.Code, maintPOST.Body.String())
|
||||
}
|
||||
assertGateBody(t, maintPOST, "maintenance", true, false)
|
||||
|
||||
maintGET := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(maintGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if maintGET.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("maintenance GET status = %d, want 503", maintGET.Code)
|
||||
}
|
||||
assertGateBody(t, maintGET, "maintenance", true, false)
|
||||
|
||||
health := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if health.Code != http.StatusOK {
|
||||
t.Fatalf("healthz under maintenance status = %d", health.Code)
|
||||
}
|
||||
var healthBody map[string]any
|
||||
if err := json.Unmarshal(health.Body.Bytes(), &healthBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if healthBody["maintenance"] != true {
|
||||
t.Fatalf("healthz flags = %#v", healthBody)
|
||||
}
|
||||
|
||||
ro := testAPIServer()
|
||||
ro.Config.ReadOnlyMode = true
|
||||
roH := ro.Router()
|
||||
|
||||
roPOST := httptest.NewRecorder()
|
||||
roH.ServeHTTP(roPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
|
||||
if roPOST.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("read-only POST without CSRF status = %d, want 503 (not csrf 403); body=%s", roPOST.Code, roPOST.Body.String())
|
||||
}
|
||||
assertGateBody(t, roPOST, "read_only", false, true)
|
||||
|
||||
roGET := httptest.NewRecorder()
|
||||
roH.ServeHTTP(roGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if roGET.Code == http.StatusServiceUnavailable {
|
||||
t.Fatalf("read-only GET must pass the gate; got 503 body=%s", roGET.Body.String())
|
||||
}
|
||||
if roGET.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("read-only GET /api/auth/me status = %d, want 401", roGET.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMetricsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := testAPIServer().Router()
|
||||
|
||||
// Drive one request so RED counters are non-empty.
|
||||
health := httptest.NewRecorder()
|
||||
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if health.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status=%d", health.Code)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("metrics status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/plain") {
|
||||
t.Fatalf("Content-Type=%q", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"http_requests_total{",
|
||||
`path="/healthz"`,
|
||||
"# TYPE sync_failures_total counter",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q in metrics:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
s.Config.AppEnv = "production"
|
||||
s.Config.MetricsPublic = false
|
||||
h := s.Router()
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "203.0.113.9:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod remote metrics status=%d want 404", rec.Code)
|
||||
}
|
||||
|
||||
loop := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
loop.RemoteAddr = "127.0.0.1:4242"
|
||||
recLoop := httptest.NewRecorder()
|
||||
h.ServeHTTP(recLoop, loop)
|
||||
if recLoop.Code != http.StatusOK {
|
||||
t.Fatalf("prod loopback metrics status=%d", recLoop.Code)
|
||||
}
|
||||
|
||||
s.Config.MetricsPublic = true
|
||||
hPub := s.Router()
|
||||
reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
reqPub.RemoteAddr = "203.0.113.9:9999"
|
||||
recPub := httptest.NewRecorder()
|
||||
hPub.ServeHTTP(recPub, reqPub)
|
||||
if recPub.Code != http.StatusOK {
|
||||
t.Fatalf("METRICS_PUBLIC remote status=%d", recPub.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body["error"] != errorCode {
|
||||
t.Fatalf("error = %#v, want %q", body["error"], errorCode)
|
||||
}
|
||||
if body["maintenance"] != maintenance {
|
||||
t.Fatalf("maintenance = %#v, want %v", body["maintenance"], maintenance)
|
||||
}
|
||||
if body["read_only"] != readOnly {
|
||||
t.Fatalf("read_only = %#v, want %v", body["read_only"], readOnly)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user