package server import ( "encoding/json" "log/slog" "net/http" "os" "path/filepath" "strings" "testing" "time" "sultan/pkg/accountant" "sultan/pkg/crypto" "sultan/pkg/ledger" "sultan/pkg/registry" ) // startAccountant startet einen echten Accountant; addr "" = freier Port. func startAccountant(t *testing.T, dataDir, addr string) *accountant.Accountant { t.Helper() if dataDir == "" { dataDir = filepath.Join(t.TempDir(), "accdata") } if addr == "" { addr = "127.0.0.1:0" } a, err := accountant.New(ctx, accountant.Config{DataDir: dataDir, Addr: addr, MetricsAddr: "127.0.0.1:0", Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } if err := a.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { a.Shutdown(ctx) }) return a } // accInvite erzeugt eine Einladung wie `sultan accountant invite`. func accInvite(t *testing.T, a *accountant.Accountant, payeeID string) string { t.Helper() l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db")) if err != nil { t.Fatal(err) } defer l.Close() tok, err := crypto.NewEnrollmentToken(a.Fingerprint(), "https://"+a.Addr()) if err != nil { t.Fatal(err) } if err := l.AddInvite(ctx, tok.Secret, payeeID, time.Now().Add(time.Hour)); err != nil { t.Fatal(err) } return tok.String() } func latestReport(t *testing.T, a *accountant.Accountant, payeeID string) (accountant.Report, time.Time, bool) { t.Helper() l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db")) if err != nil { t.Fatal(err) } defer l.Close() body, at, err := l.LatestReport(ctx, payeeID) if err != nil { return accountant.Report{}, time.Time{}, false } var r accountant.Report if err := json.Unmarshal(body, &r); err != nil { t.Fatal(err) } return r, at, true } // payeeConfig: Server meldet an den Accountant mit kurzen Intervallen. func payeeConfig(token string, heartbeat time.Duration) func(*Config) { return func(c *Config) { c.AccountantToken = token c.AccountantInterval = 20 * time.Millisecond c.AccountantHeartbeat = heartbeat c.Version = "v-test" } } func metaState(domain string) registry.NodeState { st := wpState(domain) st.Workloads[0].Meta = ®istry.WorkloadMeta{ComposeProject: "kunde-a", ComposeDir: "/srv/kunde-a", State: "running"} st.Host = ®istry.HostInfo{FQDN: "webdocker11.lan.r25dc.de", OS: "Debian GNU/Linux 13 (trixie)", DockerVersion: "29.8.2"} return st } // Der ganze Weg: Einladung → Enrollment beim Start → Bericht mit Mandanten, // Knoten und Routen samt FQDN-Ziel und Compose-Verzeichnis. func TestPayee_EnrollsAndReports(t *testing.T) { acc := startAccountant(t, "", "") s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour)) agent, _ := connectedAgent(t, s, "node-1") if r := agent.do(t, http.MethodPost, "/api/v1/state", metaState("kunde.de")); r.StatusCode != http.StatusNoContent { t.Fatalf("State: %d", r.StatusCode) } var rep accountant.Report eventually(t, "Bericht mit Zusatzdaten beim Accountant", func() bool { r, _, ok := latestReport(t, acc, "webproxy11") rep = r return ok && len(r.Routes) == 1 && r.Routes[0].ComposeDir != "" }) if rep.Version != "v-test" || rep.GeneratedAt.IsZero() { t.Errorf("Kopf: %+v", rep) } if len(rep.Tenants) != 1 || rep.Tenants[0].ID != "kunde" || rep.Tenants[0].Tier != "pro" || rep.Tenants[0].Status != "active" { t.Errorf("Mandanten: %+v", rep.Tenants) } if len(rep.Nodes) != 1 { t.Fatalf("Knoten: %+v", rep.Nodes) } if n := rep.Nodes[0]; n.ID != "node-1" || n.Tenant != "kunde" || !n.Connected || n.FQDN != "webdocker11.lan.r25dc.de" || n.OS != "Debian GNU/Linux 13 (trixie)" || n.DockerVersion != "29.8.2" || n.Hostname != "host-node-1" { t.Errorf("Knoten: %+v", n) } if r := rep.Routes[0]; r.Router != "wp" || strings.Join(r.Hosts, ",") != "kunde.de" || r.Backend != "http://100.64.0.42:80" || r.BackendFQDN != "webdocker11.lan.r25dc.de:80" || !r.Active || r.ComposeProject != "kunde-a" { t.Errorf("Route: %+v", r) } // Identität des Payees liegt im Datenverzeichnis des Servers. info, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key")) if err != nil || info.Mode().Perm() != 0o600 { t.Errorf("payee.key: %v, %v", info, err) } raw, _ := os.ReadFile(filepath.Join(s.cfg.DataDir, "accountant.json")) var id payeeIdentity if err := json.Unmarshal(raw, &id); err != nil || id.PayeeID != "webproxy11" || id.Fingerprint != acc.Fingerprint() || id.URL != "https://"+acc.Addr() { t.Errorf("accountant.json = %s (%v)", raw, err) } } // Gesendet wird bei Änderungen und als Lebenszeichen – nicht bei jedem Prüfen. func TestPayee_ReportsChangesNotEveryTick(t *testing.T) { acc := startAccountant(t, "", "") s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour)) agent, _ := connectedAgent(t, s, "node-1") var first time.Time eventually(t, "erster Bericht", func() bool { r, at, ok := latestReport(t, acc, "webproxy11") first = at return ok && len(r.Routes) == 1 }) time.Sleep(200 * time.Millisecond) // zehn Prüfintervalle ohne Änderung if _, at, _ := latestReport(t, acc, "webproxy11"); !at.Equal(first) { t.Errorf("ohne Änderung erneut gesendet (%v → %v)", first, at) } st := wpState("kunde.de") st.Workloads[0].Labels["traefik.http.routers.wp.rule"] = "Host(`kunde.de`) || Host(`www.kunde.de`)" agent.do(t, http.MethodPost, "/api/v1/state", st) eventually(t, "Änderung nachgemeldet", func() bool { r, _, _ := latestReport(t, acc, "webproxy11") return len(r.Routes) == 1 && len(r.Routes[0].Hosts) == 2 }) } func TestPayee_Heartbeat(t *testing.T) { acc := startAccountant(t, "", "") s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), 100*time.Millisecond)) connectedAgent(t, s, "node-1") var first time.Time eventually(t, "erster Bericht", func() bool { _, at, ok := latestReport(t, acc, "webproxy11") first = at return ok }) eventually(t, "Lebenszeichen ohne Änderung", func() bool { _, at, _ := latestReport(t, acc, "webproxy11") return at.After(first) }) } // Nach dem Neustart genügt accountant.json; die Einladung ist verbraucht. func TestPayee_RestartWithoutToken(t *testing.T) { acc := startAccountant(t, "", "") dir := filepath.Join(t.TempDir(), "suldata") s, _ := startServer(t, payeeConfig(accInvite(t, acc, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir }) var first time.Time eventually(t, "erster Bericht", func() bool { _, at, ok := latestReport(t, acc, "webproxy11") first = at return ok }) s.Shutdown(ctx) startServer(t, payeeConfig("", 50*time.Millisecond), func(c *Config) { c.DataDir = dir }) eventually(t, "Bericht nach Neustart ohne Einladung", func() bool { _, at, _ := latestReport(t, acc, "webproxy11") return at.After(first) }) } // Ein nicht erreichbarer Accountant hält den Ingress nicht auf; der Server // holt das Enrollment nach. func TestPayee_AccountantDownAtStart(t *testing.T) { accDir := filepath.Join(t.TempDir(), "accdata") acc := startAccountant(t, accDir, "") addr := acc.Addr() token := accInvite(t, acc, "webproxy11") acc.Shutdown(ctx) s, logs := startServer(t, payeeConfig(token, time.Hour)) if r, err := http.Get("http://" + s.ProviderAddr() + "/healthz"); err != nil || r.StatusCode != http.StatusOK { t.Fatalf("Server ohne Accountant nicht bereit: %v", err) } eventually(t, "Warnung im Log", func() bool { return strings.Contains(logs.String(), "Enrollment fehlgeschlagen") }) acc = startAccountant(t, accDir, addr) eventually(t, "Enrollment nachgeholt", func() bool { _, _, ok := latestReport(t, acc, "webproxy11") return ok }) } // Schon bei Accountant A registriert: eine Einladung von B wird nicht // eingelöst (kein stiller Wechsel), der Server läuft trotzdem. func TestPayee_OtherAccountantRejected(t *testing.T) { a := startAccountant(t, "", "") dir := filepath.Join(t.TempDir(), "suldata") s, _ := startServer(t, payeeConfig(accInvite(t, a, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir }) eventually(t, "bei A registriert", func() bool { _, _, ok := latestReport(t, a, "webproxy11"); return ok }) s.Shutdown(ctx) b := startAccountant(t, "", "") _, logs := startServer(t, payeeConfig(accInvite(t, b, "webproxy11"), time.Hour), func(c *Config) { c.DataDir = dir }) eventually(t, "Fehler im Log", func() bool { return strings.Contains(logs.String(), "anderen Accountant") }) time.Sleep(100 * time.Millisecond) if _, _, ok := latestReport(t, b, "webproxy11"); ok { t.Error("Bericht an B trotz Registrierung bei A") } } // --accountant überschreibt die Adresse aus der Einladung. func TestPayee_URLOverride(t *testing.T) { acc := startAccountant(t, "", "") l, _ := ledger.Open(ctx, filepath.Join(acc.DataDir(), "accountant.db")) tok, _ := crypto.NewEnrollmentToken(acc.Fingerprint(), "https://127.0.0.1:1") // falsche Adresse l.AddInvite(ctx, tok.Secret, "webproxy11", time.Now().Add(time.Hour)) l.Close() startServer(t, payeeConfig(tok.String(), time.Hour), func(c *Config) { c.AccountantURL = "https://" + acc.Addr() }) eventually(t, "Bericht über die überschriebene Adresse", func() bool { _, _, ok := latestReport(t, acc, "webproxy11") return ok }) } func TestPayee_ConfigErrors(t *testing.T) { noURL, err := crypto.NewEnrollmentToken("PQeFR_XBBVMz70LWWvGsFsVrFT9SZVCvQk-HQvrBaTk", "") if err != nil { t.Fatal(err) } for name, mutate := range map[string]func(*Config){ "Einladung unlesbar": func(c *Config) { c.AccountantToken = "kaputt" }, "Adresse ohne TLS": func(c *Config) { c.AccountantURL = "http://acc:8444" }, "Einladung ohne jede Adresse": func(c *Config) { c.AccountantToken = noURL.String() }, "Adresse ohne Einladung": func(c *Config) { c.AccountantURL = "https://acc:8444" }, } { t.Run(name, func(t *testing.T) { cfg := Config{DataDir: filepath.Join(t.TempDir(), "suldata"), Logger: slog.New(slog.DiscardHandler)} mutate(&cfg) if s, err := New(ctx, cfg); err == nil { s.Shutdown(ctx) t.Error("erwartet Fehler") } }) } } // Ohne Einladung und ohne accountant.json meldet der Server nichts. func TestPayee_Disabled(t *testing.T) { s, _ := startServer(t) time.Sleep(50 * time.Millisecond) if _, err := os.Stat(filepath.Join(s.cfg.DataDir, "payee.key")); !os.IsNotExist(err) { t.Errorf("payee.key ohne Accountant angelegt: %v", err) } }