package accountant import ( "bytes" "context" "crypto/ed25519" "encoding/json" "log/slog" "net/http" "os" "path/filepath" "testing" "time" "sultan/pkg/auth" "sultan/pkg/crypto" "sultan/pkg/ledger" ) var ctx = context.Background() func start(t *testing.T, dataDir string) *Accountant { t.Helper() if dataDir == "" { dataDir = filepath.Join(t.TempDir(), "accdata") } a, err := New(ctx, Config{DataDir: dataDir, 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) }) return a } // invite legt eine Einladung direkt im Ledger an (wie `sultan accountant invite`). func invite(t *testing.T, a *Accountant, payeeID string) crypto.EnrollmentToken { 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 } // enroll spielt den Payee: gepinnt, signiert. func enroll(t *testing.T, a *Accountant, key ed25519.PrivateKey, body any) *http.Response { t.Helper() raw, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPost, "https://"+a.Addr()+PathEnroll, bytes.NewReader(raw)) if key != nil { if err := (&auth.Signer{Key: key}).Sign(req); err != nil { t.Fatal(err) } } hc := &http.Client{Transport: &http.Transport{TLSClientConfig: crypto.PinnedTLSConfig(a.Fingerprint())}} resp, err := hc.Do(req) if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) return resp } func payees(t *testing.T, a *Accountant) []ledger.Payee { t.Helper() l, err := ledger.Open(ctx, filepath.Join(a.DataDir(), "accountant.db")) if err != nil { t.Fatal(err) } defer l.Close() list, err := l.ListPayees(ctx) if err != nil { t.Fatal(err) } return list } func TestNew_CreatesDataDir(t *testing.T) { dir := filepath.Join(t.TempDir(), "accdata") a := start(t, dir) info, err := os.Stat(dir) if err != nil || info.Mode().Perm() != 0o700 { t.Fatalf("Datenverzeichnis: %v, %v", info, err) } for _, f := range []string{"accountant.db", "server.crt", "server.key"} { if _, err := os.Stat(filepath.Join(dir, f)); err != nil { t.Errorf("%s fehlt: %v", f, err) } } fp := a.Fingerprint() a.Shutdown(ctx) // Neustart: gleiche Identität. b := start(t, dir) if b.Fingerprint() != fp { t.Errorf("Fingerprint nach Neustart %s, vorher %s", b.Fingerprint(), fp) } } func TestNew_PublicURLsRemembered(t *testing.T) { dir := filepath.Join(t.TempDir(), "accdata") a, err := New(ctx, Config{DataDir: dir, Addr: "127.0.0.1:0", PublicURLs: []string{"https://acc.example.de:8444", ""}, Logger: slog.New(slog.DiscardHandler)}) if err != nil { t.Fatal(err) } a.Shutdown(ctx) l, _ := ledger.Open(ctx, filepath.Join(dir, "accountant.db")) defer l.Close() urls, err := LoadPublicURLs(ctx, l) if err != nil || len(urls) != 1 || urls[0] != "https://acc.example.de:8444" { t.Errorf("LoadPublicURLs = %v, %v", urls, err) } if _, err := New(ctx, Config{DataDir: dir, PublicURLs: []string{"http://klartext:8444"}}); err == nil { t.Error("http-URL akzeptiert") } } func TestEnroll(t *testing.T) { a := start(t, "") tok := invite(t, a, "webproxy11") key, _ := auth.GenerateKey() resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "webproxy11.lan.r25dc.de"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("Status %d", resp.StatusCode) } var er EnrollResponse if err := json.NewDecoder(resp.Body).Decode(&er); err != nil || er.PayeeID != "webproxy11" { t.Errorf("Antwort %+v, %v", er, err) } list := payees(t, a) if len(list) != 1 || list[0].ID != "webproxy11" || list[0].Hostname != "webproxy11.lan.r25dc.de" || list[0].PublicKey != auth.EncodePublicKey(key.Public().(ed25519.PublicKey)) || list[0].IPAddress != "127.0.0.1" { t.Errorf("Payees = %+v", list) } // Einladung ist verbraucht. other, _ := auth.GenerateKey() if resp := enroll(t, a, other, EnrollRequest{Token: tok.Secret, Hostname: "x"}); resp.StatusCode != http.StatusForbidden { t.Errorf("verbrauchte Einladung: Status %d", resp.StatusCode) } } func TestEnroll_Errors(t *testing.T) { a := start(t, "") key, _ := auth.GenerateKey() tok := invite(t, a, "webproxy11") tests := []struct { name string key ed25519.PrivateKey body any want int }{ {"unsigniert", nil, EnrollRequest{Token: tok.Secret, Hostname: "h"}, http.StatusUnauthorized}, {"unbekannte Einladung", key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}, http.StatusForbidden}, {"ohne Hostname", key, EnrollRequest{Token: tok.Secret}, http.StatusBadRequest}, {"kein JSON", key, "kaputt", http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if resp := enroll(t, a, tt.key, tt.body); resp.StatusCode != tt.want { t.Errorf("Status %d, erwartet %d", resp.StatusCode, tt.want) } }) } if len(payees(t, a)) != 0 { t.Error("Payee trotz Fehler angelegt") } // Einladung ist nach den Fehlversuchen noch gültig. if resp := enroll(t, a, key, EnrollRequest{Token: tok.Secret, Hostname: "h"}); resp.StatusCode != http.StatusCreated { t.Errorf("gültige Einladung danach: Status %d", resp.StatusCode) } // Derselbe Schlüssel ein zweites Mal: Konflikt. tok2 := invite(t, a, "webproxy12") if resp := enroll(t, a, key, EnrollRequest{Token: tok2.Secret, Hostname: "h"}); resp.StatusCode != http.StatusConflict { t.Errorf("doppelter Schlüssel: Status %d", resp.StatusCode) } } // Kein Klartext: Der Port spricht nur TLS. func TestPlaintextRejected(t *testing.T) { a := start(t, "") resp, err := http.Post("http://"+a.Addr()+PathEnroll, "application/json", bytes.NewReader([]byte(`{}`))) if err == nil { defer resp.Body.Close() if resp.StatusCode < 400 { t.Errorf("Klartext beantwortet mit %d", resp.StatusCode) } } } func TestRun_StopsOnCancel(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) } rctx, cancel := context.WithCancel(ctx) done := make(chan error, 1) go func() { done <- a.Run(rctx) }() cancel() select { case err := <-done: if err != nil { t.Errorf("Run: %v", err) } case <-time.After(5 * time.Second): t.Fatal("Run endet nicht") } } // Fluten am Enrollment-Endpunkt werden je IP gebremst (Log und Datenbank // schonen); Raten ist bei 256 Bit Zufall ohnehin aussichtslos. func TestEnroll_RateLimited(t *testing.T) { a, err := New(ctx, Config{DataDir: filepath.Join(t.TempDir(), "accdata"), Addr: "127.0.0.1:0", EnrollLimit: 3, MetricsAddr: "none", 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, _ := auth.GenerateKey() for i := range 3 { if r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}); r.StatusCode != http.StatusForbidden { t.Fatalf("Versuch %d: %d", i+1, r.StatusCode) } } r := enroll(t, a, key, EnrollRequest{Token: "sltn_enroll_falsch", Hostname: "h"}) if r.StatusCode != http.StatusTooManyRequests || r.Header.Get("Retry-After") == "" { t.Errorf("4. Versuch: %d, Retry-After %q", r.StatusCode, r.Header.Get("Retry-After")) } // Auch unsignierte Fluten zählen. if r := enroll(t, a, nil, EnrollRequest{}); r.StatusCode != http.StatusTooManyRequests { t.Errorf("unsigniert: %d", r.StatusCode) } } func TestConfigDefaults(t *testing.T) { c := Config{}.withDefaults() if c.DataDir != "./accdata" || c.Addr != ":8444" || c.MetricsAddr != "127.0.0.1:8082" || c.EnrollLimit != 10 || c.StaleWarn != 2*time.Hour || c.StaleCrit != 6*time.Hour { t.Errorf("Defaults: %+v", c) } if c := (Config{EnrollLimit: -1}).withDefaults(); c.EnrollLimit != -1 { t.Errorf("-1 (unbegrenzt) überschrieben: %d", c.EnrollLimit) } }