261 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|