//go:build clustermode package clustermode import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/sophotechlabs/spinoza/internal/api" ) func TestTheProviderCanEndASessionFromItsOwnSide(t *testing.T) { values := oidcValues() values["persistence.enabled"] = "true" deploy(t, values) endProviderSessions(t, "bob") bob := signIn(t, "bob") if !whoami(t, bob).Authenticated { t.Fatal("bob did not sign in") } endProviderSessions(t, "bob") deadline := time.Now().Add(2 * time.Minute) ended := false for time.Now().Before(deadline) { if !whoami(t, bob).Authenticated { ended = true break } time.Sleep(time.Second) } if !ended { t.Fatal("the session the provider ended still worked") } kubectl(t, "-n", namespace, "rollout", "restart", "deployment/"+release) kubectl(t, "-n", namespace, "rollout", "status", "deployment/"+release, "--timeout="+deployWait) waitEndpoints(t) waitReachable(t) waitServing(t, "oidc") if whoami(t, bob).Authenticated { t.Fatal("the session the provider ended worked again after spinoza restarted") } if !whoami(t, signIn(t, "bob")).Authenticated { t.Fatal("bob could not sign in again after the restart") } endProviderSessions(t, "bob") } func TestALogoutTokenNamingNoSessionLeavesTheCookieAlone(t *testing.T) { values := oidcValues() values["auth.oidc.clientID"] = "spinoza-nosid" endProviderSessions(t, "bob") deploy(t, values) bob := signIn(t, "bob") if !whoami(t, bob).Authenticated { t.Fatal("bob did not sign in") } endProviderSessions(t, "bob") awaitLogLine(t, "spinoza cannot map to one session") if !whoami(t, bob).Authenticated { t.Fatal("a logout naming only a subject ended a session spinoza could not identify") } } func awaitLogLine(t *testing.T, want string) { t.Helper() deadline := time.Now().Add(2 * time.Minute) last := "" for time.Now().Before(deadline) { last = kubectl(t, "-n", namespace, "logs", "-l", "app.kubernetes.io/name="+release, "--tail=-1") if strings.Contains(last, want) { return } time.Sleep(time.Second) } t.Fatalf("spinoza never logged %q; the log ends with:\n%s", want, lastLines(last, 40)) } func lastLines(text string, keep int) string { lines := strings.Split(strings.TrimRight(text, "\n"), "\n") if len(lines) > keep { lines = lines[len(lines)-keep:] } return strings.Join(lines, "\n") } func TestEachPersonsSettingsSurviveThePodBeingReplaced(t *testing.T) { values := oidcValues() values["persistence.enabled"] = "true" deploy(t, values) alice := signIn(t, "alice") bob := signIn(t, "bob") status, message := put(t, alice, "/api/settings", `{"values":{"spinoza.theme.v1":"borg"}}`) if status != http.StatusOK { t.Fatalf("writing settings gave %d: %s", status, message) } status, message = put(t, bob, "/api/settings", `{"values":{"spinoza.theme.v1":"matrix"}}`) if status != http.StatusOK { t.Fatalf("writing bob's settings gave %d: %s", status, message) } _, before := read(t, bob, "/api/settings") if strings.Contains(before, "borg") { t.Fatalf("bob received alice's settings: %s", truncate(before)) } kubectl(t, "-n", namespace, "rollout", "restart", "deployment/"+release) kubectl(t, "-n", namespace, "rollout", "status", "deployment/"+release, "--timeout="+deployWait) waitEndpoints(t) waitReachable(t) waitServing(t, "oidc") _, after := read(t, signIn(t, "alice"), "/api/settings") if !strings.Contains(after, "borg") { t.Fatalf("alice's settings did not survive the restart: %s", truncate(after)) } if strings.Contains(after, "matrix") { t.Fatalf("alice received bob's settings: %s", truncate(after)) } _, bobAfter := read(t, signIn(t, "bob"), "/api/settings") if !strings.Contains(bobAfter, "matrix") { t.Fatalf("bob's settings did not survive the restart: %s", truncate(bobAfter)) } if strings.Contains(bobAfter, "borg") { t.Fatalf("bob received alice's settings after restart: %s", truncate(bobAfter)) } } func TestTheTimelineSurvivesThePodBeingReplaced(t *testing.T) { values := baseValues() values["auth.mode"] = "none" values["auth.allowAnonymous"] = "true" values["persistence.enabled"] = "true" deploy(t, values) held := anonymous(t) status, body := read(t, held, "/api/clusters") if status != http.StatusOK { t.Fatalf("reading clusters gave %d: %s", status, truncate(body)) } var before api.ClusterList decodeErr := json.Unmarshal([]byte(body), &before) if decodeErr != nil { t.Fatalf("decoding clusters: %v", decodeErr) } if len(before.Clusters) != 1 { t.Fatalf("clusters = %+v, want the one served cluster", before.Clusters) } id := before.Clusters[0].ID path := "/api/clusters/timeline?cluster=" + url.QueryEscape(id) + "&kinds=workloads" status, body = post(t, held, path) if status != http.StatusOK { t.Fatalf("enabling the timeline gave %d: %s", status, truncate(body)) } kubectl(t, "-n", namespace, "rollout", "restart", "deployment/"+release) kubectl(t, "-n", namespace, "rollout", "status", "deployment/"+release, "--timeout="+deployWait) waitEndpoints(t) waitReachable(t) waitServing(t, "none") held = anonymous(t) status, body = read(t, held, "/api/clusters") if status != http.StatusOK { t.Fatalf("reading clusters after restart gave %d: %s", status, truncate(body)) } var after api.ClusterList decodeErr = json.Unmarshal([]byte(body), &after) if decodeErr != nil { t.Fatalf("decoding clusters after restart: %v", decodeErr) } if len(after.Clusters) != 1 || after.Clusters[0].ID != id { t.Fatalf("clusters after restart = %+v, want %q", after.Clusters, id) } if after.Clusters[0].Timeline != "workloads" { t.Fatalf("timeline after restart = %q, want workloads", after.Clusters[0].Timeline) } probe := fmt.Sprintf("timeline-restart-%d", time.Now().UnixNano()) t.Cleanup(func() { _, _ = maybeKubectl(t, "-n", "default", "delete", "deployment", probe, "--ignore-not-found=true") }) kubectl(t, "-n", "default", "create", "deployment", probe, "--image=nginx:1.27-alpine") historyPath := "/api/history?source=change&cluster=" + url.QueryEscape(id) + "&limit=200" deadline := time.Now().Add(2 * time.Minute) for time.Now().Before(deadline) { status, body = read(t, held, historyPath) if status == http.StatusOK { var history api.History decodeErr = json.Unmarshal([]byte(body), &history) if decodeErr != nil { t.Fatalf("decoding history: %v", decodeErr) } for _, entry := range history.Entries { if entry.Name == probe { return } } } time.Sleep(time.Second) } t.Fatalf("the resumed timeline did not record %q: %s", probe, truncate(body)) } func TestTheDesktopBuildRefusesToServeACluster(t *testing.T) { binary := filepath.Join(t.TempDir(), "spinoza-desktop") build := exec.CommandContext(t.Context(), "go", "build", "-tags", "desktop", "-o", binary, "../..") if out, err := build.CombinedOutput(); err != nil { t.Fatalf("building the desktop binary: %v\n%s", err, out) } run := exec.CommandContext(t.Context(), binary, "--cluster-mode", "--public-url", base) out, err := run.CombinedOutput() if err == nil { t.Fatalf("the desktop app served a cluster:\n%s", out) } if !strings.Contains(string(out), "cannot serve a cluster") { t.Fatalf("it refused with %q, want it to say why", out) } } func endProviderSessions(t *testing.T, user string) { t.Helper() script := ` import json, urllib.parse, urllib.request KC = "http://keycloak:8080" def call(path, token=None, data=None, method=None, form=False): body = None headers = {} if data is not None: if form: body = urllib.parse.urlencode(data).encode() headers["Content-Type"] = "application/x-www-form-urlencoded" else: body = json.dumps(data).encode() headers["Content-Type"] = "application/json" if token: headers["Authorization"] = "Bearer " + token req = urllib.request.Request(KC + path, data=body, headers=headers, method=method) with urllib.request.urlopen(req) as resp: raw = resp.read() return resp.status, (json.loads(raw) if raw else None) _, tok = call("/realms/master/protocol/openid-connect/token", data={ "client_id": "admin-cli", "username": "admin", "password": "admin", "grant_type": "password"}, form=True) token = tok["access_token"] _, users = call("/admin/realms/spinoza/users?username=USER", token) status, _ = call("/admin/realms/spinoza/users/%s/logout" % users[0]["id"], token, data={}, method="POST") print("logout", status) ` encoded := base64.StdEncoding.EncodeToString([]byte(strings.ReplaceAll(script, "USER", user))) pod := fmt.Sprintf("kckick-%s-%d", user, time.Now().UnixNano()%100000) out := kubectl(t, "-n", "keycloak", "run", pod, "--rm", "-i", "--restart=Never", "--image=python:3.13-alpine", "--command", "--", "sh", "-c", "echo "+encoded+" | base64 -d | python3 -") if !strings.Contains(out, "logout 204") { t.Fatalf("the provider would not end %s's session:\n%s", user, out) } } func put(t *testing.T, held *http.Client, path, payload string) (int, string) { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, base+path, strings.NewReader(payload)) if err != nil { t.Fatalf("building the request: %v", err) } req.Header.Set("Origin", base) req.Header.Set("Content-Type", "application/json") resp, doErr := held.Do(req) if doErr != nil { t.Fatalf("PUT %s: %v", path, doErr) } defer func() { _ = resp.Body.Close() }() return resp.StatusCode, body(t, resp) } func delete(t *testing.T, held *http.Client, path string) (int, string) { t.Helper() resp := request(t, held, http.MethodDelete, path) defer func() { _ = resp.Body.Close() }() return resp.StatusCode, body(t, resp) }