package server import ( "bytes" "context" "encoding/json" "errors" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "sultan/pkg/auth" "sultan/pkg/crypto" "sultan/pkg/policy" "sultan/pkg/registry" "sultan/pkg/store" "sultan/pkg/stream" ) var ctx = context.Background() // syncBuffer sammelt Log-Ausgaben threadsicher. type syncBuffer struct { mu sync.Mutex b bytes.Buffer } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() } func eventually(t *testing.T, msg string, cond func() bool) { t.Helper() deadline := time.Now().Add(3 * time.Second) for !cond() { if time.Now().After(deadline) { t.Fatalf("Bedingung nicht erfüllt: %s", msg) } time.Sleep(5 * time.Millisecond) } } // startServer startet einen Server auf freien Ports in einem frischen // Datenverzeichnis. func startServer(t *testing.T, mutate ...func(*Config)) (*Server, *syncBuffer) { t.Helper() logs := &syncBuffer{} cfg := Config{ DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", RefreshInterval: time.Hour, Logger: slog.New(slog.NewTextHandler(logs, nil)), } for _, m := range mutate { m(&cfg) } s, err := New(ctx, cfg) if err != nil { t.Fatalf("New: %v", err) } if err := s.Start(); err != nil { t.Fatalf("Start: %v", err) } t.Cleanup(func() { sctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() s.Shutdown(sctx) }) return s, logs } func createTenant(t *testing.T, s *Server, id string, domains ...string) { t.Helper() if _, err := s.store.CreateTenant(ctx, store.Tenant{ ID: id, Name: id, Email: id + "@example.com", Tier: store.TierPro, MaxRoutes: 10, AllowedDomains: domains, ValidUntil: time.Now().AddDate(1, 0, 0), }); err != nil { t.Fatal(err) } } // issueToken erzeugt einen Enrollment-Token wie `sultan token` es tun wird. func issueToken(t *testing.T, s *Server, tenant string) string { t.Helper() tok, err := crypto.NewEnrollmentToken(s.Fingerprint()) if err != nil { t.Fatal(err) } if err := s.store.AddEnrollmentToken(ctx, tok.Secret, tenant, time.Now().Add(time.Hour)); err != nil { t.Fatal(err) } return tok.String() } // testAgent spielt die Agent-Seite mit Pinning und Signatur. type testAgent struct { base string hc *http.Client signer *auth.Signer secret string } func newAgent(t *testing.T, s *Server, token string) *testAgent { t.Helper() tok, err := crypto.ParseEnrollmentToken(token) if err != nil { t.Fatal(err) } ks := &crypto.KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")} priv, _ := auth.GenerateKey() return &testAgent{ base: "https://" + s.AgentAddr(), hc: &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.ClientTLSConfig(s.AgentAddr(), ks, tok.Fingerprint)}}, signer: &auth.Signer{Key: priv}, secret: tok.Secret, } } func (a *testAgent) do(t *testing.T, method, path string, body any) *http.Response { t.Helper() var rd io.Reader if body != nil { b, _ := json.Marshal(body) rd = bytes.NewReader(b) } req, _ := http.NewRequest(method, a.base+path, rd) if err := a.signer.Sign(req); err != nil { t.Fatal(err) } resp, err := a.hc.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func (a *testAgent) enroll(t *testing.T, nodeID string) *http.Response { return a.do(t, http.MethodPost, "/api/v1/enroll", EnrollRequest{Token: a.secret, NodeID: nodeID, Hostname: "host-" + nodeID}) } // connect hält den Stream im Hintergrund offen, bis cancel aufgerufen wird. func (a *testAgent) connect(t *testing.T) (cancel func()) { t.Helper() sctx, cancel := context.WithCancel(ctx) c := &stream.Client{URL: a.base + "/api/v1/stream", HTTPClient: a.hc, Prepare: a.signer.Sign, MinBackoff: 10 * time.Millisecond} done := make(chan struct{}) go func() { c.Run(sctx, func(stream.Event) error { return nil }); close(done) }() t.Cleanup(func() { cancel(); <-done }) return cancel } func wpState(domain string) registry.NodeState { return registry.NodeState{Workloads: []registry.Workload{{ Name: "wordpress", Host: "100.64.0.42", Labels: map[string]string{ "traefik.enable": "true", "traefik.http.routers.wp.rule": "Host(`" + domain + "`)", "traefik.http.services.wp.loadbalancer.server.port": "80", }, }}} } func providerConfig(t *testing.T, s *Server) string { t.Helper() resp, err := http.Get("http://" + s.ProviderAddr() + "/v1/traefik-config") if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } func TestConfigDefaults(t *testing.T) { c := Config{}.withDefaults() if c.DataDir != "./suldata" || c.AgentAddr != ":8443" || c.ProviderAddr != "127.0.0.1:8080" { t.Errorf("Defaults: %+v", c) } if c.InterceptorURL != "http://127.0.0.1:8081" || c.RefreshInterval != time.Minute || c.Logger == nil { t.Errorf("Defaults: %+v", c) } } func TestNew_CreatesDataDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "a", "suldata") s, err := New(ctx, Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatalf("New: %v", err) } defer s.Shutdown(ctx) info, err := os.Stat(dir) if err != nil || info.Mode().Perm() != 0o700 { t.Fatalf("Datenverzeichnis: %v, %v", info, err) } for _, f := range []string{"sultan.db", "server.key", "server.crt", "master.key"} { if _, err := os.Stat(filepath.Join(dir, f)); err != nil { t.Errorf("%s fehlt: %v", f, err) } } if info, _ := os.Stat(filepath.Join(dir, "master.key")); info.Mode().Perm() != 0o600 { t.Errorf("master.key hat %o", info.Mode().Perm()) } } // Nach einem Neustart müssen Zertifikat und Master-Key gleich bleiben, // sonst sperren sich alle Agents aus bzw. alle Lizenzen werden ungültig. func TestNew_StableIdentityAcrossRestarts(t *testing.T) { dir := filepath.Join(t.TempDir(), "suldata") cfg := Config{DataDir: dir, Logger: slog.New(slog.DiscardHandler)} a, err := New(ctx, cfg) if err != nil { t.Fatal(err) } fp, mk := a.Fingerprint(), a.MasterPublicKey() a.Shutdown(ctx) b, err := New(ctx, cfg) if err != nil { t.Fatalf("Neustart: %v", err) } defer b.Shutdown(ctx) if b.Fingerprint() != fp || !b.MasterPublicKey().Equal(mk) { t.Error("Identität hat sich beim Neustart geändert") } } func TestProviderPort(t *testing.T) { s, _ := startServer(t) if got := providerConfig(t, s); got != "{}" { t.Errorf("leere Konfiguration = %q", got) } resp, err := http.Get("http://" + s.ProviderAddr() + "/healthz") if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("/healthz: %d", resp.StatusCode) } // Agent-Routen gibt es auf dem Klartext-Port nicht. resp, _ = http.Post("http://"+s.ProviderAddr()+"/api/v1/enroll", "application/json", nil) resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("Agent-Route auf Provider-Port: %d", resp.StatusCode) } } func TestAgentPort_NoPlainHTTP(t *testing.T) { s, _ := startServer(t) resp, err := http.Get("http://" + s.AgentAddr() + "/api/v1/stream") if err == nil { defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Error("Agent-Port antwortet im Klartext mit 200") } } } // Ablauf wie im Betrieb: Enrollment, Stream, Zustand melden, Route bei // Traefik, Agent weg → Route weg und Knoten offline. func TestEndToEnd(t *testing.T) { s, _ := startServer(t) createTenant(t, s, "kunde", "kunde.de", "*.kunde.de") agent := newAgent(t, s, issueToken(t, s, "kunde")) resp := agent.enroll(t, "node-1") if resp.StatusCode != http.StatusCreated { b, _ := io.ReadAll(resp.Body) t.Fatalf("Enrollment: %d %s", resp.StatusCode, b) } var er EnrollResponse json.NewDecoder(resp.Body).Decode(&er) if er.NodeID != "node-1" || er.TenantID != "kunde" || er.MasterPublicKey != auth.EncodePublicKey(s.MasterPublicKey()) { t.Errorf("Antwort: %+v", er) } stop := agent.connect(t) eventually(t, "Stream verbunden", func() bool { return s.hub.Connected("node-1") }) eventually(t, "Knoten online", func() bool { n, _ := s.store.GetNode(ctx, "node-1") return n.Status == store.NodeOnline }) if resp := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); resp.StatusCode != http.StatusNoContent { t.Fatalf("State: %d", resp.StatusCode) } if cfg := providerConfig(t, s); !strings.Contains(cfg, "kunde__node-1__wp") || !strings.Contains(cfg, "http://100.64.0.42:80") { t.Errorf("Traefik-Konfiguration: %s", cfg) } stop() eventually(t, "Route nach Trennung entfernt", func() bool { return providerConfig(t, s) == "{}" }) eventually(t, "Knoten offline", func() bool { n, _ := s.store.GetNode(ctx, "node-1") return n.Status == store.NodeOffline }) } func TestEnroll_Errors(t *testing.T) { s, _ := startServer(t) createTenant(t, s, "kunde", "kunde.de") used := newAgent(t, s, issueToken(t, s, "kunde")) if r := used.enroll(t, "n1"); r.StatusCode != http.StatusCreated { t.Fatalf("erstes Enrollment: %d", r.StatusCode) } again := newAgent(t, s, issueToken(t, s, "kunde")) again.secret = used.secret // bereits eingelöst if r := again.enroll(t, "n2"); r.StatusCode != http.StatusForbidden { t.Errorf("verbrauchter Token: %d", r.StatusCode) } unknown := newAgent(t, s, issueToken(t, s, "kunde")) unknown.secret = crypto.TokenPrefix + strings.Repeat("A", 43) if r := unknown.enroll(t, "n3"); r.StatusCode != http.StatusForbidden { t.Errorf("unbekannter Token: %d", r.StatusCode) } if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "a__b"); r.StatusCode != http.StatusBadRequest { t.Errorf("ungültige Knoten-ID: %d", r.StatusCode) } if r := newAgent(t, s, issueToken(t, s, "kunde")).enroll(t, "n1"); r.StatusCode != http.StatusConflict { t.Errorf("doppelte Knoten-ID: %d", r.StatusCode) } // Ohne Signatur gibt es nichts. plain := newAgent(t, s, issueToken(t, s, "kunde")) req, _ := http.NewRequest(http.MethodPost, plain.base+"/api/v1/enroll", strings.NewReader(`{}`)) resp, err := plain.hc.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Errorf("unsigniert: %d", resp.StatusCode) } } func TestState_Errors(t *testing.T) { s, _ := startServer(t) createTenant(t, s, "kunde", "kunde.de") agent := newAgent(t, s, issueToken(t, s, "kunde")) agent.enroll(t, "n1") // Zustand nur bei offenem Stream (Lebendigkeit hängt am Stream). if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusConflict { t.Errorf("ohne Stream: %d", r.StatusCode) } agent.connect(t) eventually(t, "Stream", func() bool { return s.hub.Connected("n1") }) req, _ := http.NewRequest(http.MethodPost, agent.base+"/api/v1/state", strings.NewReader("kein json")) agent.signer.Sign(req) resp, err := agent.hc.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("kaputtes JSON: %d", resp.StatusCode) } stranger := newAgent(t, s, issueToken(t, s, "kunde")) // nicht enrollt if r := stranger.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")); r.StatusCode != http.StatusUnauthorized { t.Errorf("nicht registriert: %d", r.StatusCode) } } func TestState_ViolationsAreLogged(t *testing.T) { s, logs := startServer(t) createTenant(t, s, "kunde", "kunde.de") agent := newAgent(t, s, issueToken(t, s, "kunde")) agent.enroll(t, "n1") agent.connect(t) eventually(t, "Stream", func() bool { return s.hub.Connected("n1") }) if r := agent.do(t, http.MethodPost, "/api/v1/state", wpState("bank.de")); r.StatusCode != http.StatusNoContent { t.Fatalf("State: %d", r.StatusCode) } if cfg := providerConfig(t, s); cfg != "{}" { t.Errorf("fremde Domain ausgeliefert: %s", cfg) } out := logs.String() if !strings.Contains(out, "Policy-Verstoß") || !strings.Contains(out, "kind=domain") || !strings.Contains(out, "node=n1") { t.Errorf("Audit-Log fehlt:\n%s", out) } } func TestRefreshLoopAppliesTenantChanges(t *testing.T) { s, _ := startServer(t, func(c *Config) { c.RefreshInterval = 20 * time.Millisecond }) createTenant(t, s, "kunde", "kunde.de") agent := newAgent(t, s, issueToken(t, s, "kunde")) agent.enroll(t, "n1") agent.connect(t) eventually(t, "Stream", func() bool { return s.hub.Connected("n1") }) agent.do(t, http.MethodPost, "/api/v1/state", wpState("kunde.de")) if err := s.store.SetTenantStatus(ctx, "kunde", store.TenantSuspendedPayment); err != nil { t.Fatal(err) } eventually(t, "Umleitung zum Interceptor", func() bool { return strings.Contains(providerConfig(t, s), policy.InterceptorService) }) } func TestDirectory(t *testing.T) { s, _ := startServer(t) createTenant(t, s, "kunde", "kunde.de") if _, err := s.store.CreateNode(ctx, store.Node{ID: "n1", TenantID: "kunde", Hostname: "h", PublicKey: "k", IPAddress: "1.2.3.4"}); err != nil { t.Fatal(err) } d := &directory{store: s.store} nc, err := d.NodeContext(ctx, "n1") if err != nil { t.Fatal(err) } if nc.Tenant.ID != "kunde" || nc.License.TenantID != "kunde" || nc.License.MaxRoutes != 10 || !nc.License.AllowsDomain("kunde.de") || nc.Drained { t.Errorf("NodeContext: %+v", nc) } s.store.SetNodeStatus(ctx, "n1", store.NodeDrained) if nc, _ := d.NodeContext(ctx, "n1"); !nc.Drained { t.Error("gedraint nicht erkannt") } if _, err := d.NodeContext(ctx, "fehlt"); !errors.Is(err, registry.ErrUnknownNode) { t.Errorf("unbekannter Knoten: %v", err) } } // Herunterfahren darf nicht an offenen SSE-Streams hängen bleiben. func TestRun_ShutdownWithOpenStream(t *testing.T) { cfg := Config{ DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: "127.0.0.1:0", ProviderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler), } s, err := New(ctx, cfg) if err != nil { t.Fatal(err) } runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- s.Run(runCtx) }() eventually(t, "gestartet", func() bool { return s.AgentAddr() != "" && !strings.HasSuffix(s.AgentAddr(), ":0") }) createTenant(t, s, "kunde", "kunde.de") agent := newAgent(t, s, issueToken(t, s, "kunde")) agent.enroll(t, "n1") agent.connect(t) eventually(t, "Stream", func() bool { return s.hub.Connected("n1") }) cancel() select { case err := <-done: if err != nil { t.Errorf("Run: %v", err) } case <-time.After(5 * time.Second): t.Fatal("Run hängt beim Herunterfahren") } } func TestStart_PortInUse(t *testing.T) { a, _ := startServer(t) b, err := New(ctx, Config{ DataDir: filepath.Join(t.TempDir(), "suldata"), AgentAddr: a.AgentAddr(), ProviderAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler), }) if err != nil { t.Fatal(err) } defer b.Shutdown(ctx) if err := b.Start(); err == nil { t.Error("belegter Port: erwartet Fehler") } }