Files
sultan/pkg/server/payee_test.go
T

285 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = &registry.WorkloadMeta{ComposeProject: "kunde-a", ComposeDir: "/srv/kunde-a", State: "running"}
st.Host = &registry.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)
}
}