Files

272 lines
9.4 KiB
Go

package accountant
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"sultan/pkg/ledger"
)
var cmkNow = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
func cmkReport(routes ...ReportRoute) Report {
return Report{
GeneratedAt: cmkNow,
Nodes: []ReportNode{
{ID: "webdocker11", Tenant: "kunde", Connected: true},
{ID: "alt", Tenant: "kunde", Connected: false},
{ID: "wartung", Tenant: "kunde", Connected: false, Maintenance: true},
},
Routes: routes,
}
}
func okRoute(host string) ReportRoute {
return ReportRoute{Tenant: "kunde", Node: "webdocker11", Workload: "wp", Router: "wp", Protocol: "http",
Hosts: []string{host}, Backend: "http://10.0.0.11:8081", BackendFQDN: "webdocker11.lan:8081",
Active: true, ComposeDir: "/srv/kunde-a", Health: "healthy", State: "running"}
}
func findURL(t *testing.T, d CheckmkData, service string) CheckmkURL {
t.Helper()
for _, u := range d.URLs {
if u.Service == service {
return u
}
}
t.Fatalf("Service %q fehlt in %+v", service, d.URLs)
return CheckmkURL{}
}
// Zustände: OK, WARN bei Problemen der Route, CRIT bei veraltetem Bericht;
// Wartung dämpft auf OK.
func TestBuildCheckmk_States(t *testing.T) {
inactive := okRoute("fremd.de")
inactive.Active = false
down := okRoute("olat.kunde.de")
down.Down = true
sick := okRoute("krank.kunde.de")
sick.Health = "unhealthy"
gone := okRoute("weg.kunde.de")
gone.Node = "alt"
maint := okRoute("pause.kunde.de")
maint.Node, maint.Down = "wartung", true
multi := okRoute("kunde.de")
multi.Hosts = []string{"kunde.de", "www.kunde.de"}
payees := []ledger.Payee{
{ID: "frisch", Hostname: "webproxy11", LastReport: cmkNow.Add(-time.Minute)},
{ID: "lahm", Hostname: "webproxy12", LastReport: cmkNow.Add(-3 * time.Hour)},
{ID: "tot", Hostname: "webproxy13", LastReport: cmkNow.Add(-7 * time.Hour)},
{ID: "neu", Hostname: "webproxy14"},
}
reports := map[string]Report{
"frisch": cmkReport(okRoute("ok.kunde.de"), inactive, down, sick, gone, maint, multi),
"lahm": cmkReport(okRoute("lahm.kunde.de")),
"tot": cmkReport(okRoute("tot.kunde.de")),
}
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
wantPayees := map[string]int{"frisch": 0, "lahm": 1, "tot": 2, "neu": 1}
for _, p := range d.Payees {
if p.State != wantPayees[p.ID] {
t.Errorf("Payee %s: Status %d, erwartet %d (%+v)", p.ID, p.State, wantPayees[p.ID], p)
}
}
if len(d.Payees) != 4 {
t.Errorf("Payees: %+v", d.Payees)
}
tests := []struct {
service, problem string
state int
}{
{"Sultan URL ok.kunde.de", "", 0},
{"Sultan URL fremd.de", "von Policy verworfen", 1},
{"Sultan URL olat.kunde.de", "gestoppt", 1},
{"Sultan URL krank.kunde.de", "unhealthy", 1},
{"Sultan URL weg.kunde.de", "Knoten getrennt", 1},
{"Sultan URL pause.kunde.de", "Wartung", 0},
{"Sultan URL kunde.de", "", 0},
{"Sultan URL www.kunde.de", "", 0},
{"Sultan URL lahm.kunde.de", "Bericht", 1},
{"Sultan URL tot.kunde.de", "Bericht", 2},
}
for _, tt := range tests {
u := findURL(t, d, tt.service)
if u.State != tt.state || (tt.problem == "") != (u.Problem == "") || !strings.Contains(u.Problem, tt.problem) {
t.Errorf("%s: Status %d Problem %q, erwartet %d %q", tt.service, u.State, u.Problem, tt.state, tt.problem)
}
}
u := findURL(t, d, "Sultan URL ok.kunde.de")
if u.URL != "https://ok.kunde.de" || u.BackendURL != "http://webdocker11.lan:8081" || u.Payee != "frisch" ||
u.Tenant != "kunde" || u.ComposeDir != "/srv/kunde-a" {
t.Errorf("Felder: %+v", u)
}
for i := 1; i < len(d.URLs); i++ {
if d.URLs[i-1].Service > d.URLs[i].Service {
t.Errorf("nicht sortiert: %s vor %s", d.URLs[i-1].Service, d.URLs[i].Service)
}
}
}
// Derselbe Host zweimal (z. B. zwei Payees): eindeutige Servicenamen.
func TestBuildCheckmk_DuplicateHosts(t *testing.T) {
payees := []ledger.Payee{{ID: "a", LastReport: cmkNow}, {ID: "b", LastReport: cmkNow}}
reports := map[string]Report{"a": cmkReport(okRoute("kunde.de")), "b": cmkReport(okRoute("kunde.de"))}
d := buildCheckmk(payees, reports, cmkNow, 2*time.Hour, 6*time.Hour)
if len(d.URLs) != 2 || d.URLs[0].Service == d.URLs[1].Service {
t.Errorf("Servicenamen nicht eindeutig: %+v", d.URLs)
}
}
// Werte vom Kunden-Host sind Fremdeingaben: einzeilig, ASCII, ohne | und ",
// HTML-escaped; Unescaped nur bereinigt.
func TestBuildCheckmk_Sanitizes(t *testing.T) {
evil := okRoute("kunde.de")
evil.ComposeDir = "/srv/<script>alert(1)</script>\n2 \"Fake\" - OK |x"
evil.Tenant = "küche"
d := buildCheckmk([]ledger.Payee{{ID: "a", Hostname: "<b>h</b>", LastReport: cmkNow}},
map[string]Report{"a": cmkReport(evil)}, cmkNow, 2*time.Hour, 6*time.Hour)
u := d.URLs[0]
for _, s := range []string{u.ComposeDir, u.Unescaped.ComposeDir, d.Payees[0].Hostname} {
if strings.ContainsAny(s, "\n\r|\"") {
t.Errorf("nicht bereinigt: %q", s)
}
}
if strings.Contains(u.ComposeDir, "<script>") || !strings.Contains(u.ComposeDir, "&lt;script&gt;") {
t.Errorf("nicht escaped: %q", u.ComposeDir)
}
if !strings.Contains(u.Unescaped.ComposeDir, "<script>") {
t.Errorf("Unescaped verändert: %q", u.Unescaped.ComposeDir)
}
if u.Tenant != "kueche" {
t.Errorf("Umlaut: %q", u.Tenant)
}
}
var cmkLine = regexp.MustCompile(`^[0-2] "[^"|]+" - (OK|WARN|CRIT) - [ -~]*$`)
// Die eingebaute Vorlage liefert gültige Local-Check-Zeilen mit Links.
func TestRenderCheckmk_Default(t *testing.T) {
down := okRoute("olat.kunde.de")
down.Down = true
d := buildCheckmk([]ledger.Payee{{ID: "webproxy11", Hostname: "webproxy11.lan", LastReport: cmkNow.Add(-5 * time.Minute)}},
map[string]Report{"webproxy11": cmkReport(okRoute("kunde.de"), down)}, cmkNow, 2*time.Hour, 6*time.Hour)
out := renderCheckmk(t.TempDir(), d)
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("%d Zeilen:\n%s", len(lines), out)
}
for _, l := range lines {
if !cmkLine.MatchString(l) {
t.Errorf("ungültige Zeile: %q", l)
}
}
for _, want := range []string{
`0 "Sultan Payee webproxy11" - OK - `,
`0 "Sultan URL kunde.de" - OK - `,
"https://kunde.de", "http://webdocker11.lan:8081", "/srv/kunde-a", "kunde", "webdocker11",
`1 "Sultan URL olat.kunde.de" - WARN - gestoppt`,
} {
if !strings.Contains(out, want) {
t.Errorf("fehlt %q in:\n%s", want, out)
}
}
}
// Eigene Vorlage in <data-dir>/checkmk hat Vorrang und wirkt sofort; eine
// defekte fällt auf die eingebaute zurück und meldet sich als CRIT.
func TestRenderCheckmk_CustomTemplate(t *testing.T) {
dir := t.TempDir()
d := buildCheckmk([]ledger.Payee{{ID: "p", LastReport: cmkNow}}, map[string]Report{"p": cmkReport(okRoute("kunde.de"))},
cmkNow, 2*time.Hour, 6*time.Hour)
os.MkdirAll(filepath.Join(dir, "checkmk"), 0o755)
tmpl := filepath.Join(dir, "checkmk", CheckmkTemplateName)
os.WriteFile(tmpl, []byte(`{{range .URLs}}{{.State}} "{{.Service}}" - {{status .State}} - eigen {{.Unescaped.Host}}{{"\n"}}{{end}}`), 0o644)
if out := renderCheckmk(dir, d); out != "0 \"Sultan URL kunde.de\" - OK - eigen kunde.de\n" {
t.Errorf("eigene Vorlage: %q", out)
}
os.WriteFile(tmpl, []byte(`{{range .URLs}`), 0o644)
out := renderCheckmk(dir, d)
if !strings.Contains(out, `2 "Sultan Accountant Vorlage" - CRIT - `) || !strings.Contains(out, `"Sultan URL kunde.de"`) {
t.Errorf("defekte Vorlage: %q", out)
}
os.WriteFile(tmpl, []byte(`{{.GibtsNicht}}`), 0o644)
if out := renderCheckmk(dir, d); !strings.Contains(out, `2 "Sultan Accountant Vorlage"`) {
t.Errorf("Ausführungsfehler: %q", out)
}
}
func TestDumpCheckmk(t *testing.T) {
dir := t.TempDir()
path, err := DumpCheckmk(dir, false)
if err != nil || path != filepath.Join(dir, "checkmk", CheckmkTemplateName) {
t.Fatalf("DumpCheckmk = %q, %v", path, err)
}
data, _ := os.ReadFile(path)
if string(data) != defaultCheckmkTemplate {
t.Error("Inhalt weicht von der eingebauten Vorlage ab")
}
if _, err := DumpCheckmk(dir, false); err == nil {
t.Error("vorhandene Datei überschrieben")
}
if _, err := DumpCheckmk(dir, true); err != nil {
t.Errorf("--force: %v", err)
}
}
// Ende zu Ende: Bericht eines Payees erscheint auf dem Loopback-Port.
func TestCheckmkEndpoint(t *testing.T) {
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", 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) })
key := enrolledPayee(t, a, "webproxy11")
rep := cmkReport(okRoute("kunde.de"))
rep.GeneratedAt = time.Now()
body, _ := json.Marshal(rep)
postReport(t, a, key, body)
resp, err := http.Get("http://" + a.MetricsAddr() + "/metrics/checkmk")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") ||
!strings.Contains(string(out), `0 "Sultan URL kunde.de"`) || !strings.Contains(string(out), `0 "Sultan Payee webproxy11"`) {
t.Errorf("Status %d, %q:\n%s", resp.StatusCode, resp.Header.Get("Content-Type"), out)
}
}
func TestCheckmkEndpoint_Disabled(t *testing.T) {
a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", MetricsAddr: "none",
Logger: slog.New(slog.DiscardHandler)})
if err != nil {
t.Fatal(err)
}
if err := a.Start(); err != nil {
t.Fatal(err)
}
defer a.Shutdown(ctx)
if a.MetricsAddr() != "" {
t.Errorf("MetricsAddr = %q, erwartet leer", a.MetricsAddr())
}
}