check / check (push) Successful in 13s
maintenance_mode was only reported by the health check; every request was still served. One middleware in routes.go, applied to /v1/image/ and /v1/e/ only, now answers them with 503, a Retry-After of MaintenanceRetryAfterSeconds and the JSON error body while it is on. It calls Server.MaintenanceMode(), which had no caller. The health check stays 200 and reports maintenance_mode: the image's Docker HEALTHCHECK requests it, a 503 there would make the container unhealthy, and upaas marks a deploy failed when its container is unhealthy. The login and URL generator pages and /metrics keep working. Documented in README.md and config.example.yml. Model: opus-5-5
170 lines
4.8 KiB
Go
170 lines
4.8 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
|
)
|
|
|
|
// unsignedImagePath is an image URL that carries no signature.
|
|
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
|
|
|
|
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
|
|
// mode is on, both image routes answer 503 Service Unavailable with a
|
|
// Retry-After header and the JSON error body the image handlers send.
|
|
func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.MaintenanceMode = true
|
|
|
|
requests := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, unsignedImagePath},
|
|
{http.MethodHead, unsignedImagePath},
|
|
{http.MethodGet, "/v1/e/token/cat.jpg"},
|
|
}
|
|
|
|
for _, tc := range requests {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), tc.method, tc.path, nil))
|
|
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want %d",
|
|
rec.Code, http.StatusServiceUnavailable)
|
|
}
|
|
|
|
retryAfter := rec.Header().Get("Retry-After")
|
|
|
|
seconds, err := strconv.Atoi(retryAfter)
|
|
if err != nil || seconds <= 0 {
|
|
t.Errorf("Retry-After = %q, want a positive number of seconds",
|
|
retryAfter)
|
|
}
|
|
|
|
// A HEAD response carries no body.
|
|
if tc.method == http.MethodHead {
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
Status int `json:"status"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
err = json.NewDecoder(rec.Body).Decode(&body)
|
|
if err != nil {
|
|
t.Fatalf("body is not JSON: %v", err)
|
|
}
|
|
|
|
if body.Error == "" || body.Status != http.StatusServiceUnavailable ||
|
|
body.Timestamp == "" {
|
|
t.Errorf("body = %+v, want an error, status %d and a timestamp",
|
|
body, http.StatusServiceUnavailable)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestImageRequestsServedWithoutMaintenanceMode verifies that while
|
|
// maintenance mode is off, image requests reach the image handlers instead
|
|
// of the 503. The handlers refuse an unsigned image URL with 401 and a token
|
|
// they cannot decrypt with 400, so either status shows a request got through.
|
|
func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.MaintenanceMode = false
|
|
|
|
requests := []struct {
|
|
method string
|
|
path string
|
|
want int
|
|
}{
|
|
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
|
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
|
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
|
|
}
|
|
|
|
for _, tc := range requests {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(
|
|
t.Context(), tc.method, tc.path, nil))
|
|
t.Logf("status %d, body %s", rec.Code, rec.Body.String())
|
|
|
|
if rec.Code != tc.want {
|
|
t.Errorf("status = %d, want %d from the image handler",
|
|
rec.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMaintenanceModeKeepsOtherRoutes verifies that while maintenance mode
|
|
// is on, the health check still answers 200 and reports it, and the login
|
|
// page and /metrics still answer 200. The image's Docker HEALTHCHECK
|
|
// requests the health check: a 503 there would make the container
|
|
// unhealthy, and upaas marks a deploy failed when its container is
|
|
// unhealthy.
|
|
func TestMaintenanceModeKeepsOtherRoutes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := newTestServer(t)
|
|
s.config.MaintenanceMode = true
|
|
|
|
// /metrics is routed only when its username is set.
|
|
s.config.MetricsUsername = "metrics"
|
|
s.config.MetricsPassword = "metrics-password"
|
|
s.SetupRoutes()
|
|
|
|
rec := httptest.NewRecorder()
|
|
s.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, "/.well-known/healthcheck.json", nil))
|
|
t.Logf("health check status %d, body %s", rec.Code, rec.Body.String())
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("health check status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var health healthcheck.Response
|
|
|
|
err := json.NewDecoder(rec.Body).Decode(&health)
|
|
if err != nil || !health.Maintenance {
|
|
t.Errorf("health check maintenance_mode = %v (error %v), want true",
|
|
health.Maintenance, err)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
s.ServeHTTP(rec, clientRequest(t, http.MethodGet, nil, firstClient, ""))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("login page status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, "/metrics", nil)
|
|
req.SetBasicAuth(s.config.MetricsUsername, s.config.MetricsPassword)
|
|
|
|
rec = httptest.NewRecorder()
|
|
s.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("/metrics status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
}
|