package cli import ( "bytes" "context" "log/slog" "os" "path/filepath" "slices" "strings" "testing" "time" "sultan/pkg/accountant" "sultan/pkg/crypto" "sultan/pkg/ledger" ) // initAccDir initialisiert ein Datenverzeichnis wie der erste Start von // `sultan accountant`. func initAccDir(t *testing.T, publicURLs ...string) string { t.Helper() dir := filepath.Join(t.TempDir(), "accdata") a, err := accountant.New(context.Background(), accountant.Config{ DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: publicURLs, Logger: slog.New(slog.DiscardHandler), }) if err != nil { t.Fatal(err) } a.Shutdown(context.Background()) return dir } func acc(t *testing.T, dir string, args ...string) (int, string, string) { t.Helper() full := append([]string{"accountant"}, args...) full = append(full, "--data-dir="+dir) return run(context.Background(), full...) } func TestAccountantFlags(t *testing.T) { cfg, err := parseAccountantFlags(nil, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.DataDir != "./accdata" || cfg.Addr != ":8444" || len(cfg.PublicURLs) != 0 || cfg.MetricsAddr != "127.0.0.1:8082" || cfg.EnrollLimit != 10 || cfg.StaleWarn != 2*time.Hour || cfg.StaleCrit != 6*time.Hour { t.Errorf("Defaults: %+v", cfg) } cfg, err = parseAccountantFlags([]string{"--metrics-addr=none", "--enroll-limit=-1", "--stale-warn=30m", "--stale-crit=1h"}, &bytes.Buffer{}, noEnv) if err != nil { t.Fatal(err) } if cfg.MetricsAddr != "none" || cfg.EnrollLimit != -1 || cfg.StaleWarn != 30*time.Minute || cfg.StaleCrit != time.Hour { t.Errorf("Overrides: %+v", cfg) } env := map[string]string{"SULTAN_PUBLIC_URL": "https://a:8444,https://b:8444"} cfg, err = parseAccountantFlags([]string{"--data-dir=/srv/acc", "--addr=:9444"}, &bytes.Buffer{}, func(k string) string { return env[k] }) if err != nil { t.Fatal(err) } if cfg.DataDir != "/srv/acc" || cfg.Addr != ":9444" || strings.Join(cfg.PublicURLs, " ") != "https://a:8444 https://b:8444" { t.Errorf("Overrides: %+v", cfg) } } // Einladung mit gemerkter Adresse und Fingerprint des Accountants; in der // DB liegt nur der Hash. func TestAccountantInvite(t *testing.T) { dir := initAccDir(t, "https://acc.example.de:8444") code, stdout, stderr := acc(t, dir, "invite", "--payee=webproxy11") if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr) } tk, err := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)) if err != nil { t.Fatalf("Ausgabe ist keine Einladung: %q (%v)", stdout, err) } cert, _ := crypto.LoadOrCreateServerCert(dir) if tk.Fingerprint != cert.Fingerprint || tk.ServerURL != "https://acc.example.de:8444" { t.Errorf("Einladung %+v", tk) } if !strings.Contains(stderr, "webproxy11") || !strings.Contains(stderr, "SULTAN_ACCOUNTANT_TOKEN") { t.Errorf("Hinweis fehlt: %q", stderr) } // Gleiche Einladung lässt sich im Ledger einlösen (Hash passt). l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db")) if err != nil { t.Fatal(err) } defer l.Close() if _, err := l.Enroll(context.Background(), tk.Secret, ledger.Payee{Hostname: "h", PublicKey: "k"}); err != nil { t.Errorf("Einladung nicht einlösbar: %v", err) } // --server-url überschreibt, --ttl wird geprüft. code, stdout, _ = acc(t, dir, "invite", "--payee=webproxy12", "--server-url=https://other:8444", "--ttl=7d") if tk, _ := crypto.ParseEnrollmentToken(strings.TrimSpace(stdout)); code != 0 || tk.ServerURL != "https://other:8444" { t.Errorf("--server-url: code=%d %+v", code, tk) } for _, args := range [][]string{ {"invite"}, // --payee fehlt {"invite", "--payee=webproxy13", "--ttl=x"}, // ttl unlesbar {"invite", "--payee=webproxy13", "--server-url=http://klartext"}, } { if code, _, _ := acc(t, dir, args...); code != 2 { t.Errorf("%v: code=%d, erwartet 2", args, code) } } if code, _, stderr := acc(t, dir, "invite", "--payee=webproxy11"); code != 1 || !strings.Contains(stderr, "existiert") { t.Errorf("schon registriert: code=%d stderr=%q", code, stderr) } } func TestAccountantPayees(t *testing.T) { dir := initAccDir(t) if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "ID") || strings.Contains(stdout, "webproxy11") { t.Errorf("leere Liste: code=%d %q", code, stdout) } l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db")) l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour)) l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "webproxy11.lan.r25dc.de", PublicKey: "k", IPAddress: "10.0.0.5"}) l.Close() code, stdout, _ := acc(t, dir, "payee", "list") if code != 0 || !strings.Contains(stdout, "webproxy11") || !strings.Contains(stdout, "webproxy11.lan.r25dc.de") || !strings.Contains(stdout, "10.0.0.5") { t.Errorf("Liste: code=%d %q", code, stdout) } if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 0 { t.Errorf("delete: code=%d", code) } if code, _, _ := acc(t, dir, "payee", "delete", "--id=webproxy11"); code != 1 { t.Errorf("zweites delete: code=%d", code) } } // Ein Tippfehler bei --data-dir legt keine neue Accountant-Identität an. func TestAccountant_UninitializedDataDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "tippfehler") if code, _, stderr := acc(t, dir, "payee", "list"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") { t.Errorf("code=%d stderr=%q", code, stderr) } if code, _, stderr := acc(t, dir, "invite", "--payee=x1"); code != 1 || !strings.Contains(stderr, "nicht initialisiert") { t.Errorf("invite: code=%d stderr=%q", code, stderr) } } func TestAccountant_Usage(t *testing.T) { if code, stdout, _ := run(context.Background(), "accountant", "help"); code != 0 || !strings.Contains(stdout, "invite") { t.Errorf("help: code=%d %q", code, stdout) } if code, _, stderr := run(context.Background(), "accountant", "gibts-nicht"); code != 2 || !strings.Contains(stderr, "unbekannt") { t.Errorf("unbekannt: code=%d %q", code, stderr) } if code, stdout, _ := run(context.Background(), "help"); code != 0 || !strings.Contains(stdout, "sultan accountant") { t.Errorf("Hauptusage ohne accountant: %q", stdout) } } // payee list zeigt den letzten Bericht, payee report seinen Inhalt. func TestAccountantPayeeReport(t *testing.T) { dir := initAccDir(t) l, _ := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db")) l.AddInvite(context.Background(), "tok", "webproxy11", time.Now().Add(time.Hour)) l.Enroll(context.Background(), "tok", ledger.Payee{Hostname: "h", PublicKey: "k", IPAddress: "10.0.0.5"}) if code, stdout, _ := acc(t, dir, "payee", "list"); code != 0 || !strings.Contains(stdout, "LAST_REPORT") || !strings.Contains(stdout, "nie") { t.Errorf("ohne Bericht: code=%d %q", code, stdout) } if code, _, stderr := acc(t, dir, "payee", "report", "--id=webproxy11"); code != 1 || !strings.Contains(stderr, "nicht gefunden") { t.Errorf("report ohne Bericht: code=%d %q", code, stderr) } l.SaveReport(context.Background(), "webproxy11", []byte(`{"version":"v1","routes":[{"hosts":["kunde.de"]}]}`), nil) l.Close() code, stdout, _ := acc(t, dir, "payee", "list") if code != 0 || strings.Contains(stdout, "nie") || !strings.Contains(stdout, time.Now().Format("2006-01-02")) { t.Errorf("mit Bericht: code=%d %q", code, stdout) } code, stdout, _ = acc(t, dir, "payee", "report", "--id=webproxy11") if code != 0 || !strings.Contains(stdout, `"kunde.de"`) || !strings.Contains(stdout, "\n ") { t.Errorf("report: code=%d %q (erwartet eingerücktes JSON)", code, stdout) } if code, _, _ := acc(t, dir, "payee", "report"); code != 2 { t.Errorf("report ohne --id: code=%d", code) } } // dump-checkmk legt die eingebaute Vorlage zum Anpassen ins Datenverzeichnis. func TestAccountantDumpCheckmk(t *testing.T) { dir := initAccDir(t) code, stdout, _ := acc(t, dir, "dump-checkmk") want := filepath.Join(dir, "checkmk", accountant.CheckmkTemplateName) if code != 0 || strings.TrimSpace(stdout) != want { t.Fatalf("code=%d stdout=%q", code, stdout) } if code, _, stderr := acc(t, dir, "dump-checkmk"); code != 1 || !strings.Contains(stderr, "--force") { t.Errorf("zweiter Lauf: code=%d %q", code, stderr) } if code, _, _ := acc(t, dir, "dump-checkmk", "--force"); code != 0 { t.Errorf("--force: code=%d", code) } // Nicht initialisiertes Verzeichnis: nichts anlegen. if code, _, _ := acc(t, filepath.Join(t.TempDir(), "tippfehler"), "dump-checkmk"); code != 1 { t.Errorf("Tippfehler: code=%d", code) } } // accReport legt einen Bericht samt Historie ab, wie es der Endpunkt tut. func accReport(t *testing.T, dir, payeeID string, at time.Time, rep accountant.Report) { t.Helper() l, err := ledger.Open(context.Background(), filepath.Join(dir, "accountant.db")) if err != nil { t.Fatal(err) } defer l.Close() l.SetClock(func() time.Time { return at }) if list, _ := l.ListPayees(context.Background()); !slices.ContainsFunc(list, func(p ledger.Payee) bool { return p.ID == payeeID }) { l.AddInvite(context.Background(), "tok-"+payeeID, payeeID, at.Add(time.Hour)) if _, err := l.Enroll(context.Background(), "tok-"+payeeID, ledger.Payee{Hostname: payeeID, PublicKey: "k-" + payeeID}); err != nil { t.Fatal(err) } } if err := accountant.StoreReport(context.Background(), l, payeeID, rep); err != nil { t.Fatal(err) } } func cliReport(node string, routes ...accountant.ReportRoute) accountant.Report { return accountant.Report{ GeneratedAt: time.Now(), Tenants: []accountant.ReportTenant{{ID: "kunde", Name: "Kunde A"}}, Nodes: []accountant.ReportNode{{ID: node, Tenant: "kunde", FQDN: node + ".lan.r25dc.de", Connected: true, OS: "Debian 13", DockerVersion: "29.8.2"}}, Routes: routes, } } func cliRoute(node, router string, hosts ...string) accountant.ReportRoute { return accountant.ReportRoute{Tenant: "kunde", Node: node, Router: router, Protocol: "http", Hosts: hosts, Workload: "kunde-a-" + router + "-1", Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan.r25dc.de:8081", Active: true, ComposeDir: "/srv/kunde-a", ComposeProject: "kunde-a", State: "running", Health: "healthy"} } // where: Störfall – wohin springen, seit wann, wo vorher? func TestAccountantWhere(t *testing.T) { dir := initAccDir(t) t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local) accReport(t, dir, "webproxy11", t1, cliReport("webdocker07", cliRoute("webdocker07", "wp", "kunde.de"), cliRoute("webdocker07", "olat", "olat.kunde.de"))) t2 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.Local) accReport(t, dir, "webproxy11", t2, cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"))) code, stdout, stderr := acc(t, dir, "where", "kunde.de") if code != 0 { t.Fatalf("code=%d stderr=%s", code, stderr) } for _, want := range []string{ "kunde.de, www.kunde.de", "Router wp", "kunde (Kunde A)", "webproxy11", "webdocker11.lan.r25dc.de:8081", "kunde-a-wp-1", "/srv/kunde-a", "healthy", "Debian 13", "2026-10-01 10:00", "webdocker07", "2026-09-01 10:00", // vorher "olat.kunde.de", // verschwunden } { if !strings.Contains(stdout, want) { t.Errorf("fehlt %q in:\n%s", want, stdout) } } if code, _, stderr := acc(t, dir, "where", "gibts-nicht.de"); code != 1 || !strings.Contains(stderr, "nichts gefunden") { t.Errorf("kein Treffer: code=%d %q", code, stderr) } if code, _, _ := acc(t, dir, "where"); code != 2 { t.Errorf("ohne Suchbegriff: code=%d", code) } } func TestAccountantRoutes(t *testing.T) { dir := initAccDir(t) down := cliRoute("webdocker11", "olat", "olat.kunde.de") down.Down = true accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"), down)) code, stdout, _ := acc(t, dir, "routes") if code != 0 || !strings.Contains(stdout, "HOST") || !strings.Contains(stdout, "webdocker11.lan.r25dc.de:8081") { t.Fatalf("code=%d\n%s", code, stdout) } lines := strings.Split(strings.TrimSpace(stdout), "\n") if len(lines) != 3 || !strings.HasPrefix(lines[1], "kunde.de") || !strings.Contains(lines[2], "gestoppt") { t.Errorf("Tabelle:\n%s", stdout) } if code, stdout, _ := acc(t, dir, "routes", "--tenant=andere"); code != 0 || strings.Contains(stdout, "kunde.de") { t.Errorf("--tenant: %q", stdout) } } // Kunden: anlegen, ändern (nur angegebene Felder), filtern, zuordnen. func TestAccountantCustomers(t *testing.T) { dir := initAccDir(t) if code, _, stderr := acc(t, dir, "customer", "add", "--id=familie", "--name=Familie", "--group=familie", "--cycle=j"); code != 0 { t.Fatalf("add: code=%d %s", code, stderr) } accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de"))) // Automatisch angelegt: kunde. code, stdout, _ := acc(t, dir, "customer", "list", "--unclassified") if code != 0 || !strings.Contains(stdout, "kunde") || strings.Contains(stdout, "familie") || !strings.Contains(stdout, "nicht eingeordnet") { t.Errorf("--unclassified: code=%d\n%s", code, stdout) } if code, _, stderr := acc(t, dir, "customer", "set", "--id=kunde", "--cycle=quarterly", "--flat-rate=89,00", "--note=Kumpel – Nebenabrede Bier", "--group=extern"); code != 0 { t.Fatalf("set: code=%d %s", code, stderr) } if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--name=Kunde A GmbH"); code != 0 { // nur der Name t.Fatal("set --name") } code, stdout, _ = acc(t, dir, "customer", "list") for _, want := range []string{"Kunde A GmbH", "quartalsweise", "89,00", "extern", "Nebenabrede", "jährlich"} { if !strings.Contains(stdout, want) { t.Errorf("fehlt %q:\n%s", want, stdout) } } if code, stdout, _ := acc(t, dir, "customer", "list", "--group=familie"); code != 0 || strings.Contains(stdout, "kunde ") || !strings.Contains(stdout, "Familie") { t.Errorf("--group:\n%s", stdout) } if code, _, _ := acc(t, dir, "customer", "set", "--id=kunde", "--no-flat-rate"); code != 0 { t.Error("--no-flat-rate") } // Tippfehler-Mandant auf Nebencluster zuordnen; where zeigt den Kunden. if code, _, stderr := acc(t, dir, "customer", "map", "--tenant=kunde-a", "--payee=webproxy12", "--customer=kunde"); code != 0 { t.Fatalf("map: %d %s", code, stderr) } code, stdout, _ = acc(t, dir, "customer", "list") if !strings.Contains(stdout, "webproxy12/kunde-a") { t.Errorf("Zuordnung nicht gelistet:\n%s", stdout) } if _, stdout, _ := acc(t, dir, "where", "kunde.de"); !strings.Contains(stdout, "Kunde A GmbH") || !strings.Contains(stdout, "quartalsweise") { t.Errorf("where ohne Kunde:\n%s", stdout) } if code, _, _ := acc(t, dir, "customer", "unmap", "--tenant=kunde-a", "--payee=webproxy12"); code != 0 { t.Error("unmap") } for _, args := range [][]string{ {"customer", "add", "--id=x1"}, // --name fehlt {"customer", "add", "--id=x1", "--name=X", "--cycle=w"}, // Zahlweise unbekannt {"customer", "set", "--id=kunde", "--flat-rate=abc"}, {"customer", "set", "--id=kunde", "--flat-rate=1", "--no-flat-rate"}, {"customer", "map", "--tenant=x"}, } { if code, _, _ := acc(t, dir, args...); code != 2 { t.Errorf("%v: code=%d, erwartet 2", args, code) } } if code, _, _ := acc(t, dir, "customer", "delete", "--id=familie"); code != 0 { t.Error("delete") } if code, _, _ := acc(t, dir, "customer", "set", "--id=familie", "--name=x"); code != 1 { t.Error("set nach delete") } } // Katalog: Klasse und Preis mit Gültig-ab, Kundenpreis. func TestAccountantProducts(t *testing.T) { dir := initAccDir(t) mustAcc := func(args ...string) string { t.Helper() code, stdout, stderr := acc(t, dir, args...) if code != 0 { t.Fatalf("%v: code=%d %s", args, code, stderr) } return stdout } mustAcc("product", "add", "--id=pingvin", "--name=Pingvin Share", "--class=free") mustAcc("product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00") mustAcc("product", "set", "--id=pingvin", "--from=2027-03", "--class=billable", "--price=5,00") mustAcc("product", "set", "--id=olat", "--from=2027-01", "--price=52,00") // Klasse bleibt mustAcc("customer", "add", "--id=k-1001", "--name=Kunde A") mustAcc("product", "price", "--id=olat", "--customer=k-1001", "--price=39,00", "--from=2026-11") out := mustAcc("product", "list") for _, want := range []string{"pingvin", "Pingvin Share", "ab 2027-03: billable 5,00", "olat", "49,00", "ab 2027-01: billable 52,00"} { if !strings.Contains(out, want) { t.Errorf("fehlt %q:\n%s", want, out) } } for _, args := range [][]string{ {"product", "add", "--id=x1", "--name=X", "--class=teuer"}, {"product", "add", "--id=x1", "--name=X"}, // --class fehlt {"product", "set", "--id=olat", "--from=März"}, {"product", "set", "--id=olat", "--from=2027-02", "--price=1", "--no-price"}, {"product", "price", "--id=olat", "--customer=k-1001"}, // --price fehlt } { if code, _, _ := acc(t, dir, args...); code != 2 && code != 1 { t.Errorf("%v: code=%d, erwartet Fehler", args, code) } } if code, _, _ := acc(t, dir, "product", "set", "--id=gibts-nicht", "--from=2027-01", "--class=free"); code != 1 { t.Errorf("unbekanntes Produkt: code=%d", code) } } // assign: Produkt, Klasse, Beginn je Posten – bequem über den Host. func TestAccountantAssign(t *testing.T) { dir := initAccDir(t) acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable") accReport(t, dir, "webproxy11", time.Now(), cliReport("webdocker11", cliRoute("webdocker11", "wp", "kunde.de", "www.kunde.de"), cliRoute("webdocker11", "olat", "olat.kunde.de"))) if code, _, stderr := acc(t, dir, "assign", "--host=olat.kunde.de", "--product=olat", "--start=2026-11"); code != 0 { t.Fatalf("assign: %d %s", code, stderr) } if code, _, stderr := acc(t, dir, "assign", "--tenant=kunde", "--router=wp", "--class=internal"); code != 0 { t.Fatalf("assign --router: %d %s", code, stderr) } code, stdout, _ := acc(t, dir, "assignments") if code != 0 || !strings.Contains(stdout, "olat.kunde.de") || !strings.Contains(stdout, "2026-11") || !strings.Contains(stdout, "internal") { t.Errorf("assignments:\n%s", stdout) } if code, _, _ := acc(t, dir, "assign", "--host=www.kunde.de", "--clear"); code != 0 { t.Error("--clear") } if _, stdout, _ := acc(t, dir, "assignments"); strings.Contains(stdout, "internal") { t.Errorf("nach --clear:\n%s", stdout) } for _, args := range [][]string{ {"assign", "--host=gibts-nicht.de", "--class=free"}, {"assign", "--host=kunde.de", "--product=gibts-nicht"}, {"assign", "--host=kunde.de"}, // nichts zugeordnet {"assign", "--class=free"}, // weder --host noch --tenant/--router } { if code, _, _ := acc(t, dir, args...); code == 0 { t.Errorf("%v akzeptiert", args) } } } // billing: Monats-CSV auf stdout bzw. in eine Datei. func TestAccountantBilling(t *testing.T) { dir := initAccDir(t) acc(t, dir, "product", "add", "--id=olat", "--name=OLAT", "--class=billable", "--price=49,00") r := cliRoute("webdocker11", "olat", "olat.kunde.de") r.Product = "olat" accReport(t, dir, "webproxy11", time.Date(2026, 10, 17, 10, 0, 0, 0, time.Local), cliReport("webdocker11", r)) acc(t, dir, "customer", "set", "--id=kunde", "--cycle=q", "--flat-rate=89,00") code, stdout, stderr := acc(t, dir, "billing", "--month=2026-10") if code != 0 { t.Fatalf("code=%d %s", code, stderr) } for _, want := range []string{"Monat;Kunde", "2026-10;kunde;Kunde A;;quartalsweise;Pauschale", "89,00;267,00", "olat.kunde.de", "billable;olat", "49,00;147,00"} { if !strings.Contains(stdout, want) { t.Errorf("fehlt %q:\n%s", want, stdout) } } out := filepath.Join(t.TempDir(), "2026-10.csv") if code, _, _ := acc(t, dir, "billing", "--month=2026-10", "--out="+out); code != 0 { t.Fatal("--out") } if data, _ := os.ReadFile(out); !strings.Contains(string(data), "olat.kunde.de") { t.Errorf("Datei: %s", data) } if code, _, _ := acc(t, dir, "billing"); code != 2 { t.Errorf("ohne --month: code=%d", code) } if code, _, _ := acc(t, dir, "billing", "--month=Oktober"); code != 2 { t.Errorf("ungültiger Monat: code=%d", code) } }