Files
sultan/pkg/accountant/accountant_test.go
T

261 lines
8.0 KiB
Go

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