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) } }