Files
goclaw/internal/http/gateway_upgrade_test.go

306 lines
10 KiB
Go

package http
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeGatewayUpgradeRunner struct {
tags []string
err error
}
func (r *fakeGatewayUpgradeRunner) Start(tag string) error {
r.tags = append(r.tags, tag)
return r.err
}
func TestValidGatewayUpgradeTag(t *testing.T) {
tests := []struct {
tag string
want bool
}{
{"latest", true},
{"v3.12.0", true},
{"v3.12.0-beta.1", true},
{"v3.12.0-rc.2", true},
{"", false},
{"3.12.0", false},
{"v3.12", false},
{"v3.12.0-beta", false},
{"https://example.com/goclaw.tar.gz", false},
{"v3.12.0;reboot", false},
{"../v3.12.0", false},
{"v3.12.0 linux", false},
}
for _, tt := range tests {
if got := validGatewayUpgradeTag(tt.tag); got != tt.want {
t.Fatalf("validGatewayUpgradeTag(%q) = %v, want %v", tt.tag, got, tt.want)
}
}
}
func TestGatewayUpgradeStatusMissingReturnsIdle(t *testing.T) {
h := &GatewayUpgradeHandler{StatusPath: filepath.Join(t.TempDir(), "missing.json"), TriggerToken: "secret-token"}
req := httptest.NewRequest(http.MethodGet, "/v1/system/gateway/upgrade/status", nil)
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-status-owner"))
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if body["state"] != "idle" {
t.Fatalf("state = %v, want idle", body["state"])
}
}
func TestGatewayUpgradeStartAcceptsValidTag(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: runner,
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"v3.12.0"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-start-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 1 || runner.tags[0] != "v3.12.0" {
t.Fatalf("runner tags = %#v, want [v3.12.0]", runner.tags)
}
}
func TestGatewayUpgradeStartRejectsInvalidTag(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: runner,
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"https://example.com/x"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-invalid-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 0 {
t.Fatalf("runner should not be called, got %#v", runner.tags)
}
}
func TestGatewayUpgradeStartRejectsRunningJob(t *testing.T) {
dir := t.TempDir()
statusPath := filepath.Join(dir, "status.json")
if err := os.WriteFile(statusPath, []byte(`{"state":"running"}`), 0o600); err != nil {
t.Fatalf("write status: %v", err)
}
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{StatusPath: statusPath, TriggerToken: "secret-token", Runner: runner}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-running-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("want 409, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 0 {
t.Fatalf("runner should not be called, got %#v", runner.tags)
}
}
func TestGatewayUpgradeStartAllowsStaleRunningJob(t *testing.T) {
dir := t.TempDir()
statusPath := filepath.Join(dir, "status.json")
staleStartedAt := time.Now().UTC().Add(-gatewayUpgradeRunningMaxAge - time.Minute).Format(time.RFC3339)
if err := os.WriteFile(statusPath, []byte(`{"state":"running","startedAt":"`+staleStartedAt+`"}`), 0o600); err != nil {
t.Fatalf("write status: %v", err)
}
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{StatusPath: statusPath, TriggerToken: "secret-token", Runner: runner}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-stale-running-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 1 || runner.tags[0] != "latest" {
t.Fatalf("runner tags = %#v, want [latest]", runner.tags)
}
}
func TestGatewayUpgradeTriggerTokenGuard(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: runner,
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req = req.WithContext(ownerCtx(req.Context(), "gateway-token-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 0 {
t.Fatalf("runner should not be called, got %#v", runner.tags)
}
}
func TestGatewayUpgradeRejectsNonMasterScope(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: runner,
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(tenantAdminCtx(req.Context(), "gateway-tenant-admin"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 0 {
t.Fatalf("runner should not be called, got %#v", runner.tags)
}
}
func TestGatewayUpgradeStartRunnerError(t *testing.T) {
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: &fakeGatewayUpgradeRunner{err: errors.New("boom")},
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-runner-error-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("want 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestGatewayUpgradeFailsClosedWithoutConfiguredTriggerToken(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
Runner: runner,
}
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req = req.WithContext(ownerCtx(req.Context(), "gateway-no-token-owner"))
w := httptest.NewRecorder()
h.handleStart(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("want 503, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 0 {
t.Fatalf("runner should not be called, got %#v", runner.tags)
}
}
func TestGatewayUpgradeRegisterRoutes(t *testing.T) {
runner := &fakeGatewayUpgradeRunner{}
h := &GatewayUpgradeHandler{
StatusPath: filepath.Join(t.TempDir(), "status.json"),
TriggerToken: "secret-token",
Runner: runner,
}
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPost, "/v1/system/gateway/upgrade", bytes.NewBufferString(`{"tag":"latest"}`))
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d: %s", w.Code, w.Body.String())
}
if len(runner.tags) != 1 || runner.tags[0] != "latest" {
t.Fatalf("runner tags = %#v, want [latest]", runner.tags)
}
}
func TestNewGatewayUpgradeHandlerFromEnvDefaults(t *testing.T) {
t.Setenv("GOCLAW_UPGRADE_SCRIPT", "")
t.Setenv("GOCLAW_UPGRADE_STATUS_PATH", "")
t.Setenv("GOCLAW_UPGRADE_TRIGGER_TOKEN", "secret-token")
h := NewGatewayUpgradeHandlerFromEnv()
if h.ScriptPath != defaultGatewayUpgradeScript {
t.Fatalf("ScriptPath = %q, want %q", h.ScriptPath, defaultGatewayUpgradeScript)
}
if h.StatusPath != defaultGatewayUpgradeStatus {
t.Fatalf("StatusPath = %q, want %q", h.StatusPath, defaultGatewayUpgradeStatus)
}
if h.TriggerToken != "secret-token" {
t.Fatalf("TriggerToken was not loaded from env")
}
}
func TestGatewayUpgradeStatusRejectsInvalidJSON(t *testing.T) {
statusPath := filepath.Join(t.TempDir(), "status.json")
if err := os.WriteFile(statusPath, []byte(`{bad json`), 0o600); err != nil {
t.Fatalf("write status: %v", err)
}
h := &GatewayUpgradeHandler{StatusPath: statusPath, TriggerToken: "secret-token"}
req := httptest.NewRequest(http.MethodGet, "/v1/system/gateway/upgrade/status", nil)
req.Header.Set(gatewayUpgradeTokenHeader, "secret-token")
req = req.WithContext(ownerCtx(req.Context(), "gateway-invalid-status-owner"))
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("want 500, got %d: %s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), statusPath) || strings.Contains(w.Body.String(), "decode upgrade status") {
t.Fatalf("response leaked internal status details: %s", w.Body.String())
}
}