285 lines
10 KiB
Go
285 lines
10 KiB
Go
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)
|
||
}
|
||
}
|