Files
Felis/cmd/felis/backupnow_test.go
T
Lemon-miaow ac3a557566 fix(cli): Sync picker hides system servers; keep the two 409 refusals apart
Two defects from the live Sync drill:

- The picker listed the system servers (login/lobby), which the backup API can
  never accept (reserved names, no servers row): the pick died in name
  validation with a raw "server name is reserved" error. backupPickable now
  filters them out; the halt picker keeps them on purpose (break-glass retains
  full power over system servers).
- backupErrorFromResponse mapped every 409 to the stopped gate, so the new
  world-volume refusal would have displayed the wrong reason. The 409 arm now
  keys on the body's error code; a code-less body still reads as the stopped
  gate.

Live (auditfix38): the picker shows only user servers; a world-less pick shows
the API's own "no world volume yet — start it once" text; the not_stopped text
is unchanged.
2026-09-23 07:49:07 +08:00

174 lines
6.0 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/platform"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
const bgControlNS = "felis-system"
func internalAPIObjs(clusterIP, token string) []client.Object {
return []client.Object{
&corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: platform.APIInternalServiceName, Namespace: bgControlNS},
Spec: corev1.ServiceSpec{ClusterIP: clusterIP},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: naming.ServiceTokenSecretName, Namespace: bgControlNS},
Data: map[string][]byte{naming.ServiceTokenSecretKey: []byte(token)},
},
}
}
func fakeInternalAPIClient(t *testing.T, objs ...client.Object) client.Client {
t.Helper()
return fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(objs...).Build()
}
func TestResolveInternalAPI(t *testing.T) {
t.Run("happy: ClusterIP + token -> baseURL, token", func(t *testing.T) {
cl := fakeInternalAPIClient(t, internalAPIObjs("10.43.0.9", "s3cr3t")...)
base, tok, err := resolveInternalAPI(context.Background(), cl, bgControlNS)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != "s3cr3t" {
t.Fatalf("token = %q, want s3cr3t", tok)
}
// The URL must carry the resolved ClusterIP + internal port — not the cluster-DNS
// name, which the on-node host cannot resolve.
want := fmt.Sprintf("http://10.43.0.9:%d", platform.APIInternalPort)
if base != want {
t.Fatalf("baseURL = %q, want %q", base, want)
}
})
t.Run("headless Service (no ClusterIP) -> error", func(t *testing.T) {
cl := fakeInternalAPIClient(t, internalAPIObjs(corev1.ClusterIPNone, "s3cr3t")...)
if _, _, err := resolveInternalAPI(context.Background(), cl, bgControlNS); err == nil {
t.Fatal("want error for a Service with no ClusterIP")
}
})
t.Run("empty token -> error", func(t *testing.T) {
cl := fakeInternalAPIClient(t, internalAPIObjs("10.43.0.9", "")...)
if _, _, err := resolveInternalAPI(context.Background(), cl, bgControlNS); err == nil {
t.Fatal("want error for a Secret with no token")
}
})
}
func TestRequestBackup(t *testing.T) {
hc := &http.Client{Timeout: 2 * time.Second}
t.Run("202 -> backing_up, and the request carries Bearer + os_user", func(t *testing.T) {
var gotAuth, gotOSUser, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotPath = r.URL.Path
var body struct {
OSUser string `json:"os_user"`
}
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
gotOSUser = body.OSUser
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
out, err := requestBackup(context.Background(), hc, srv.URL, "tok123", "survival", "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.name != "survival" || out.status != "backing_up" {
t.Fatalf("outcome = %+v, want {survival backing_up}", out)
}
if gotAuth != "Bearer tok123" {
t.Fatalf("Authorization = %q, want Bearer tok123", gotAuth)
}
if gotOSUser != "alice" {
t.Fatalf("os_user in body = %q, want alice", gotOSUser)
}
if gotPath != "/api/v1/internal/servers/survival/backup" {
t.Fatalf("path = %q, want the internal backup path", gotPath)
}
})
// The status-code → friendly-message mapping is the peer's real logic; assert each
// well-known code produces a distinct operator-facing message.
cases := []struct {
name string
code int
body string // optional JSON error body
expect string
}{
{"409 not_stopped", http.StatusConflict, "", "must be stopped"},
{"409 no_world_volume surfaces the API's own text", http.StatusConflict,
`{"error":{"code":"no_world_volume","message":"this server has no world volume yet — start it once to create it, then retry"}}`,
"no world volume yet"},
{"503 backup_unavailable", http.StatusServiceUnavailable, "", "not configured"},
{"404 not found", http.StatusNotFound, "", "no such server"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.code)
if tc.body != "" {
_, _ = io.WriteString(w, tc.body)
}
}))
defer srv.Close()
_, err := requestBackup(context.Background(), hc, srv.URL, "tok", "survival", "alice")
if err == nil || !strings.Contains(err.Error(), tc.expect) {
t.Fatalf("err = %v, want it to contain %q", err, tc.expect)
}
})
}
t.Run("transport failure -> unreachable message (break-glass needs the API alive)", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close() // dial will fail
_, err := requestBackup(context.Background(), hc, srv.URL, "tok", "survival", "alice")
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("err = %v, want an 'unreachable' transport error", err)
}
})
}
// The Sync picker must not offer system servers: the backup API validates names
// and resolves a servers-table row, so a lobby/login pick can only die in
// validation — a dead choice in an emergency console.
func TestBackupPickable(t *testing.T) {
got := backupPickable([]haltableServer{
{name: "lobby", phase: "Running", system: true},
{name: "login", phase: "Running", system: true},
{name: "test-one", phase: "Stopped"},
})
if len(got) != 1 || got[0].name != "test-one" || got[0].system {
t.Fatalf("backupPickable = %+v, want only the user server", got)
}
// Survivors keep their input order (the picker's cursor math depends on it).
got = backupPickable([]haltableServer{
{name: "alpha"}, {name: "login", system: true}, {name: "beta"},
})
if len(got) != 2 || got[0].name != "alpha" || got[1].name != "beta" {
t.Fatalf("backupPickable order = %+v, want [alpha beta]", got)
}
}