Ctrl K
ComunidadGuías…

test/clustermode/lifecycle_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//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)
}