184 lines
6.4 KiB
Go
184 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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},
|
|
},
|
|
// The console presents the ops token; the proxy's felis-service-token sits
|
|
// beside it and must not be the one picked up.
|
|
&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "felis-ops-token", Namespace: bgControlNS},
|
|
Data: map[string][]byte{"token": []byte(token)},
|
|
},
|
|
&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "felis-service-token", Namespace: bgControlNS},
|
|
Data: map[string][]byte{"token": []byte("proxy-" + 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)
|
|
}
|
|
// backup-now ends its run on this error alone.
|
|
if !errors.Is(err, errBackupAPIUnreachable) {
|
|
t.Fatalf("err = %v, want it to wrap errBackupAPIUnreachable", 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)
|
|
}
|
|
}
|