Files

281 lines
9.9 KiB
Go

package ledger
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/store"
)
var ctx = context.Background()
func open(t *testing.T) *Ledger {
t.Helper()
l, err := Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
return l
}
func payee(id, key string) Payee {
return Payee{ID: id, Hostname: id + ".lan", PublicKey: key, IPAddress: "10.0.0.1"}
}
// Einladung → Enrollment legt den Payee an; die Einladung bestimmt die ID.
func TestEnroll(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
if err := l.AddInvite(ctx, "sltn_enroll_a", "webproxy11", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
p := payee("ignoriert", "key-a")
got, err := l.Enroll(ctx, "sltn_enroll_a", p)
if err != nil {
t.Fatalf("Enroll: %v", err)
}
want := Payee{ID: "webproxy11", Hostname: "ignoriert.lan", PublicKey: "key-a", IPAddress: "10.0.0.1", EnrolledAt: now}
if got != want {
t.Errorf("Enroll = %+v, erwartet %+v", got, want)
}
list, err := l.ListPayees(ctx)
if err != nil || len(list) != 1 || list[0] != want {
t.Errorf("ListPayees = %+v, %v", list, err)
}
if byKey, err := l.GetPayeeByPublicKey(ctx, "key-a"); err != nil || byKey != want {
t.Errorf("GetPayeeByPublicKey = %+v, %v", byKey, err)
}
if _, err := l.GetPayeeByPublicKey(ctx, "fremd"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unbekannter Schlüssel: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
l.AddInvite(ctx, "tok-2", "webproxy12", now.Add(time.Hour))
l.AddInvite(ctx, "tok-alt", "webproxy13", now.Add(time.Minute))
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-1")); err != nil {
t.Fatal(err)
}
if _, err := l.Enroll(ctx, "gibts-nicht", payee("x", "key-9")); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unbekannte Einladung: %v", err)
}
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-8")); !errors.Is(err, store.ErrTokenUsed) {
t.Errorf("verbrauchte Einladung: %v", err)
}
// Schlüssel schon vergeben: Einladung bleibt unverbraucht.
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-1")); !errors.Is(err, store.ErrExists) {
t.Errorf("doppelter Schlüssel: %v", err)
}
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-2")); err != nil {
t.Errorf("Einladung nach Fehlschlag verbraucht: %v", err)
}
now = now.Add(2 * time.Minute)
if _, err := l.Enroll(ctx, "tok-alt", payee("x", "key-3")); !errors.Is(err, store.ErrTokenExpired) {
t.Errorf("abgelaufene Einladung: %v", err)
}
if _, err := l.Enroll(ctx, "tok-x", Payee{ID: "x", PublicKey: "k"}); err == nil {
t.Error("Payee ohne Hostname akzeptiert")
}
}
func TestAddInvite_Validation(t *testing.T) {
l := open(t)
future := time.Now().Add(time.Hour)
l.AddInvite(ctx, "tok-1", "webproxy11", future)
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
tests := []struct {
name, token, id string
expires time.Time
want error
}{
{"ungültige ID", "t", "Web Proxy", future, store.ErrInvalid},
{"doppelter Unterstrich", "t", "a__b", future, store.ErrInvalid},
{"ohne Token", "", "webproxy12", future, store.ErrInvalid},
{"Ablauf in der Vergangenheit", "t", "webproxy12", time.Now().Add(-time.Minute), store.ErrInvalid},
{"Payee schon registriert", "t", "webproxy11", future, store.ErrExists},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := l.AddInvite(ctx, tt.token, tt.id, tt.expires); !errors.Is(err, tt.want) {
t.Errorf("AddInvite = %v, erwartet %v", err, tt.want)
}
})
}
}
func TestDeletePayee(t *testing.T) {
l := open(t)
l.AddInvite(ctx, "tok-1", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
if err := l.DeletePayee(ctx, "webproxy11"); err != nil {
t.Fatal(err)
}
if list, _ := l.ListPayees(ctx); len(list) != 0 {
t.Errorf("nach Löschen: %+v", list)
}
if err := l.DeletePayee(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("zweites Löschen: %v", err)
}
// Neu einladen (z. B. nach Verlust von payee.key).
if err := l.AddInvite(ctx, "tok-2", "webproxy11", time.Now().Add(time.Hour)); err != nil {
t.Errorf("neue Einladung nach Löschen: %v", err)
}
}
func TestSettings(t *testing.T) {
l := open(t)
if v, err := l.Setting(ctx, "public_urls"); err != nil || v != "" {
t.Errorf("leer: %q, %v", v, err)
}
l.SetSetting(ctx, "public_urls", `["https://a:8444"]`)
l.SetSetting(ctx, "public_urls", `["https://b:8444"]`)
if v, _ := l.Setting(ctx, "public_urls"); v != `["https://b:8444"]` {
t.Errorf("Setting = %q", v)
}
}
// Der jeweils letzte Bericht je Payee; ListPayees zeigt seinen Zeitpunkt,
// Löschen des Payees entfernt ihn mit.
func TestReports(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("ohne Bericht: %v", err)
}
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":1}`), nil); err != nil {
t.Fatal(err)
}
now = now.Add(time.Minute)
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":2}`), nil); err != nil {
t.Fatal(err)
}
body, at, err := l.LatestReport(ctx, "webproxy11")
if err != nil || string(body) != `{"v":2}` || !at.Equal(now) {
t.Errorf("LatestReport = %s, %v, %v", body, at, err)
}
list, _ := l.ListPayees(ctx)
if len(list) != 1 || !list[0].LastReport.Equal(now) {
t.Errorf("ListPayees = %+v", list)
}
if err := l.SaveReport(ctx, "gibts-nicht", []byte(`{}`), nil); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Bericht für unbekannten Payee: %v", err)
}
l.DeletePayee(ctx, "webproxy11")
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Bericht nach Löschen des Payees: %v", err)
}
}
func loc(tenant, node, router string, hosts ...string) RouteRecord {
return RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts,
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan:8081", Workload: router, ComposeDir: "/srv/" + router}
}
// Jeder Bericht führt die Historie fort: neue Orte öffnen, fehlende enden,
// bestehende behalten first_seen und übernehmen geänderte Angaben.
func TestRouteHistory(t *testing.T) {
l := open(t)
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
now := t1
l.now = func() time.Time { return now }
for _, id := range []string{"webproxy11", "webproxy12"} {
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
l.Enroll(ctx, "tok-"+id, payee("x", "key-"+id))
}
if err := l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{
loc("kunde", "webdocker07", "wp", "kunde.de"), loc("kunde", "webdocker07", "olat", "olat.kunde.de"),
}); err != nil {
t.Fatal(err)
}
l.SaveReport(ctx, "webproxy12", []byte(`{}`), []RouteRecord{loc("andere", "n9", "web", "andere.de")})
now = t1.Add(24 * time.Hour)
moved := loc("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de") // Umzug + neuer Host
olat := loc("kunde", "webdocker07", "olat", "olat.kunde.de", "lms.kunde.de")
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat})
all, err := l.RouteHistory(ctx, HistoryFilter{})
if err != nil {
t.Fatal(err)
}
type key struct{ payee, node, router string }
got := map[key]RouteSpan{}
for _, s := range all {
got[key{s.Payee, s.Node, s.Router}] = s
}
if len(all) != 4 {
t.Fatalf("%d Einträge: %+v", len(all), all)
}
old := got[key{"webproxy11", "webdocker07", "wp"}]
if !old.FirstSeen.Equal(t1) || !old.Ended.Equal(now) || !old.LastSeen.Equal(t1) {
t.Errorf("alter Ort: %+v", old)
}
cur := got[key{"webproxy11", "webdocker11", "wp"}]
if !cur.FirstSeen.Equal(now) || !cur.Ended.IsZero() || strings.Join(cur.Hosts, ",") != "kunde.de,www.kunde.de" {
t.Errorf("neuer Ort: %+v", cur)
}
o := got[key{"webproxy11", "webdocker07", "olat"}]
if !o.FirstSeen.Equal(t1) || !o.LastSeen.Equal(now) || !o.Ended.IsZero() || len(o.Hosts) != 2 {
t.Errorf("geänderte Hosts behalten first_seen: %+v", o)
}
if other := got[key{"webproxy12", "n9", "web"}]; !other.Ended.IsZero() {
t.Errorf("Bericht eines Payees beendet Orte eines anderen: %+v", other)
}
// Wieder da: neuer Zeitraum, der alte bleibt beendet.
now = t1.Add(48 * time.Hour)
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat, loc("kunde", "webdocker07", "wp", "kunde.de")})
spans, _ := l.RouteHistory(ctx, HistoryFilter{Host: "kunde.de"})
var open07 int
for _, s := range spans {
if s.Node == "webdocker07" && s.Router == "wp" && s.Ended.IsZero() {
open07++
}
}
if open07 != 1 {
t.Errorf("Wiederkehr: %+v", spans)
}
// Filter: Host (Teiltreffer), Mandant, Payee; nur offene.
if s, _ := l.RouteHistory(ctx, HistoryFilter{Host: "lms"}); len(s) != 1 || s[0].Router != "olat" {
t.Errorf("Host-Teiltreffer: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{Tenant: "andere"}); len(s) != 1 {
t.Errorf("Mandant: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{Payee: "webproxy12"}); len(s) != 1 {
t.Errorf("Payee: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{OpenOnly: true, Tenant: "kunde"}); len(s) != 3 {
t.Errorf("nur offene: %d %+v", len(s), s)
}
}
// Ein Payee wird gelöscht: Die Historie bleibt (Abrechnung, Verlauf).
func TestRouteHistory_SurvivesPayeeDelete(t *testing.T) {
l := open(t)
l.AddInvite(ctx, "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(ctx, "tok", payee("x", "k"))
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{loc("kunde", "n1", "wp", "kunde.de")})
l.DeletePayee(ctx, "webproxy11")
if s, _ := l.RouteHistory(ctx, HistoryFilter{}); len(s) != 1 {
t.Errorf("Historie nach Löschen: %+v", s)
}
}