package auth import ( "context" "crypto/ed25519" "errors" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "sultan/pkg/store" ) var ctx = context.Background() // fakeDirectory ist ein In-Memory-Ersatz für den Store. type fakeDirectory struct { nodes map[string]store.Node // nach Public Key tenants map[string]store.Tenant err error // simuliert z. B. eine nicht erreichbare Datenbank } func (d *fakeDirectory) GetNodeByPublicKey(_ context.Context, key string) (store.Node, error) { if d.err != nil { return store.Node{}, d.err } n, ok := d.nodes[key] if !ok { return store.Node{}, store.ErrNotFound } return n, nil } func (d *fakeDirectory) GetTenant(_ context.Context, id string) (store.Tenant, error) { t, ok := d.tenants[id] if !ok { return store.Tenant{}, store.ErrNotFound } return t, nil } type testEnv struct { dir *fakeDirectory signer *Signer ver *Verifier now time.Time } // newEnv: Mandant t1 im Community-Tier (Homelab) mit registriertem Knoten n1. func newEnv(t *testing.T) *testEnv { t.Helper() priv, err := GenerateKey() if err != nil { t.Fatal(err) } pub := EncodePublicKey(priv.Public().(ed25519.PublicKey)) env := &testEnv{ dir: &fakeDirectory{ nodes: map[string]store.Node{pub: {ID: "n1", TenantID: "t1", PublicKey: pub, Status: store.NodeOnline}}, tenants: map[string]store.Tenant{"t1": {ID: "t1", Tier: store.TierCommunity, Status: store.TenantActive}}, }, now: time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC), } clock := func() time.Time { return env.now } env.signer = &Signer{Key: priv, now: clock} env.ver = &Verifier{Directory: env.dir, now: clock} return env } func (e *testEnv) signed(t *testing.T, method, url, body string) *http.Request { t.Helper() var rd io.Reader if body != "" { rd = strings.NewReader(body) } r := httptest.NewRequest(method, url, rd) // httptest.NewRequest erzeugt einen Server-Request; zum Signieren // braucht Sign die Client-Sicht mit Schema. r.URL.Scheme, r.URL.Host = "https", r.Host if err := e.signer.Sign(r); err != nil { t.Fatalf("Sign: %v", err) } return r } func TestVerify_Valid(t *testing.T) { env := newEnv(t) id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/api/v1/stream?since=5", "")) if err != nil { t.Fatalf("Verify: %v", err) } if id.Node.ID != "n1" || id.Tenant.ID != "t1" { t.Errorf("Identität = %+v", id) } } // Auch im Community-/Homelab-Betrieb gibt es keinen Zugang ohne Signatur. func TestVerify_CommunityTierRequiresSignature(t *testing.T) { env := newEnv(t) r := httptest.NewRequest(http.MethodGet, "https://ingress/api/v1/stream", nil) if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) { t.Errorf("unsignierter Request: erwartet ErrUnauthenticated, bekommen %v", err) } } func TestVerify_BodyCoveredAndPreserved(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state", `{"labels":{}}`) if _, err := env.ver.Verify(r); err != nil { t.Fatalf("Verify: %v", err) } body, _ := io.ReadAll(r.Body) if string(body) != `{"labels":{}}` { t.Errorf("Body nach Verify = %q, Handler muss ihn noch lesen können", body) } } func TestVerify_MissingHeaders(t *testing.T) { for _, h := range []string{HeaderKey, HeaderTimestamp, HeaderNonce, HeaderSignature} { t.Run(h, func(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodGet, "https://ingress/x", "") r.Header.Del(h) if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) { t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err) } }) } } func TestVerify_Tampering(t *testing.T) { other, _ := GenerateKey() tests := map[string]func(r *http.Request){ "Methode": func(r *http.Request) { r.Method = http.MethodDelete }, "Pfad": func(r *http.Request) { r.URL.Path = "/api/v1/admin" }, "Query": func(r *http.Request) { r.URL.RawQuery = "since=0" }, "Body": func(r *http.Request) { r.Body = io.NopCloser(strings.NewReader(`{"labels":{"böse":1}}`)) }, "Zeitstempel": func(r *http.Request) { r.Header.Set(HeaderTimestamp, "1790000001") }, "Nonce": func(r *http.Request) { r.Header.Set(HeaderNonce, "andere-nonce-1234") }, "Signatur Müll": func(r *http.Request) { r.Header.Set(HeaderSignature, "!!!") }, "Signatur kurz": func(r *http.Request) { r.Header.Set(HeaderSignature, "AAAA") }, "Schlüssel Müll": func(r *http.Request) { r.Header.Set(HeaderKey, "AAAA") }, "fremder Schlüssel": func(r *http.Request) { r.Header.Set(HeaderKey, EncodePublicKey(other.Public().(ed25519.PublicKey))) }, } for name, tamper := range tests { t.Run(name, func(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodPost, "https://ingress/api/v1/state?since=5", `{"labels":{}}`) tamper(r) if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) { t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err) } }) } } func TestVerify_UnknownKey(t *testing.T) { env := newEnv(t) stranger, _ := GenerateKey() env.signer.Key = stranger // korrekt signiert, aber nicht registriert if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) { t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err) } } func TestVerify_ClockSkew(t *testing.T) { tests := []struct { name string offset time.Duration // Zeit des Signers relativ zum Server ok bool }{ {"4 Minuten alt", -4 * time.Minute, true}, {"4 Minuten voraus", 4 * time.Minute, true}, {"6 Minuten alt", -6 * time.Minute, false}, {"6 Minuten voraus", 6 * time.Minute, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { env := newEnv(t) signTime := env.now.Add(tt.offset) env.signer.now = func() time.Time { return signTime } _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")) if tt.ok && err != nil { t.Errorf("erwartet OK, bekommen %v", err) } if !tt.ok && !errors.Is(err, ErrClockSkew) { t.Errorf("erwartet ErrClockSkew, bekommen %v", err) } }) } env := newEnv(t) r := env.signed(t, http.MethodGet, "https://ingress/x", "") r.Header.Set(HeaderTimestamp, "gestern") if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) { t.Errorf("unlesbarer Zeitstempel: erwartet ErrUnauthenticated, bekommen %v", err) } } func TestVerify_Replay(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodGet, "https://ingress/x", "") replay := r.Clone(ctx) if _, err := env.ver.Verify(r); err != nil { t.Fatal(err) } if _, err := env.ver.Verify(replay); !errors.Is(err, ErrReplay) { t.Errorf("erwartet ErrReplay, bekommen %v", err) } } // Ein Angreifer ohne gültige Signatur darf keine Nonces "verbrennen". func TestVerify_InvalidSignatureDoesNotConsumeNonce(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodGet, "https://ingress/x", "") forged := r.Clone(ctx) forged.Header.Set(HeaderSignature, r.Header.Get(HeaderKey)+"xx") if _, err := env.ver.Verify(forged); !errors.Is(err, ErrUnauthenticated) { t.Fatalf("Fälschung: %v", err) } if _, err := env.ver.Verify(r); err != nil { t.Errorf("echter Request nach Fälschung abgelehnt: %v", err) } } func TestVerify_RejectsPlainHTTP(t *testing.T) { env := newEnv(t) r := env.signed(t, http.MethodGet, "https://ingress/x", "") r.TLS = nil // korrekt signiert, aber über Klartext angekommen if _, err := env.ver.Verify(r); !errors.Is(err, ErrInsecureTransport) { t.Errorf("erwartet ErrInsecureTransport, bekommen %v", err) } } func TestVerify_BodyTooLarge(t *testing.T) { env := newEnv(t) env.ver.MaxBodySize = 10 r := env.signed(t, http.MethodPost, "https://ingress/x", strings.Repeat("x", 11)) if _, err := env.ver.Verify(r); !errors.Is(err, ErrBodyTooLarge) { t.Errorf("erwartet ErrBodyTooLarge, bekommen %v", err) } } // Verify beantwortet "wer ist das?". Gesperrt wird hier nur bei // Quarantäne; Zahlungsverzug, Ablauf und Drain regelt die Policy-Engine // über das Routing – der Agent braucht den Kanal weiterhin (z. B. für // lease_renew oder Reaktivierung). func TestVerify_StatusPolicy(t *testing.T) { tests := []struct { name string tenant store.TenantStatus node store.NodeStatus want error }{ {"aktiv", store.TenantActive, store.NodeOnline, nil}, {"Zahlungsverzug", store.TenantSuspendedPayment, store.NodeOnline, nil}, {"abgelaufen", store.TenantExpired, store.NodeOnline, nil}, {"Knoten gedraint", store.TenantActive, store.NodeDrained, nil}, {"Quarantäne", store.TenantQuarantined, store.NodeOnline, ErrForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { env := newEnv(t) for k, n := range env.dir.nodes { n.Status = tt.node env.dir.nodes[k] = n } tn := env.dir.tenants["t1"] tn.Status = tt.tenant env.dir.tenants["t1"] = tn id, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")) if !errors.Is(err, tt.want) { t.Fatalf("erwartet %v, bekommen %v", tt.want, err) } if err == nil && (id.Tenant.Status != tt.tenant || id.Node.Status != tt.node) { t.Errorf("Identität ohne Status: %+v", id) } }) } } func TestVerify_TenantMissing(t *testing.T) { env := newEnv(t) delete(env.dir.tenants, "t1") if _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")); !errors.Is(err, ErrUnauthenticated) { t.Errorf("erwartet ErrUnauthenticated, bekommen %v", err) } } // Infrastrukturfehler sind keine Authentifizierungsfehler (500 statt 401). func TestVerify_DirectoryError(t *testing.T) { env := newEnv(t) dbDown := errors.New("Datenbank weg") env.dir.err = dbDown _, err := env.ver.Verify(env.signed(t, http.MethodGet, "https://ingress/x", "")) if !errors.Is(err, dbDown) || errors.Is(err, ErrUnauthenticated) { t.Errorf("erwartet den DB-Fehler, bekommen %v", err) } if StatusCode(err) != http.StatusInternalServerError { t.Errorf("StatusCode = %d", StatusCode(err)) } } func TestStatusCode(t *testing.T) { tests := map[error]int{ nil: http.StatusOK, ErrUnauthenticated: http.StatusUnauthorized, ErrClockSkew: http.StatusUnauthorized, ErrReplay: http.StatusUnauthorized, ErrForbidden: http.StatusForbidden, ErrInsecureTransport: http.StatusUpgradeRequired, ErrBodyTooLarge: http.StatusRequestEntityTooLarge, } for err, want := range tests { if got := StatusCode(err); got != want { t.Errorf("StatusCode(%v) = %d, erwartet %d", err, got, want) } } } // Enrollment: Der Schlüssel ist noch nicht registriert. Die Signatur beweist // nur den Besitz des privaten Schlüssels; den Rest prüft store.Enroll. func TestVerifyUnregistered(t *testing.T) { env := newEnv(t) fresh, _ := GenerateKey() env.signer.Key = fresh r := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`) replay := r.Clone(ctx) replay.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_x"}`)) pub, err := env.ver.VerifyUnregistered(r) if err != nil { t.Fatalf("VerifyUnregistered: %v", err) } if !pub.Equal(fresh.Public()) { t.Error("falscher Public Key zurückgegeben") } if body, _ := io.ReadAll(r.Body); string(body) != `{"token":"sltn_x"}` { t.Errorf("Body nicht erhalten: %q", body) } if _, err := env.ver.VerifyUnregistered(replay); !errors.Is(err, ErrReplay) { t.Errorf("Replay: erwartet ErrReplay, bekommen %v", err) } tampered := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", `{"token":"sltn_x"}`) tampered.Body = io.NopCloser(strings.NewReader(`{"token":"sltn_y"}`)) if _, err := env.ver.VerifyUnregistered(tampered); !errors.Is(err, ErrUnauthenticated) { t.Errorf("manipuliert: erwartet ErrUnauthenticated, bekommen %v", err) } plain := env.signed(t, http.MethodPost, "https://ingress/api/v1/enroll", "") plain.TLS = nil if _, err := env.ver.VerifyUnregistered(plain); !errors.Is(err, ErrInsecureTransport) { t.Errorf("Klartext: erwartet ErrInsecureTransport, bekommen %v", err) } } func TestVerifier_DefaultLimits(t *testing.T) { var v Verifier if v.maxSkew() != 5*time.Minute { t.Errorf("MaxSkew-Default = %v", v.maxSkew()) } if v.maxBodySize() != 1<<20 { t.Errorf("MaxBodySize-Default = %d", v.maxBodySize()) } } func TestVerify_NonceFormat(t *testing.T) { for _, nonce := range []string{"kurz", strings.Repeat("x", 200)} { env := newEnv(t) r := httptest.NewRequest(http.MethodGet, "https://ingress/x", nil) r.Header.Set(HeaderKey, EncodePublicKey(env.signer.Key.Public().(ed25519.PublicKey))) r.Header.Set(HeaderTimestamp, strconv.FormatInt(env.now.Unix(), 10)) r.Header.Set(HeaderNonce, nonce) r.Header.Set(HeaderSignature, "AAAA") if _, err := env.ver.Verify(r); !errors.Is(err, ErrUnauthenticated) { t.Errorf("Nonce der Länge %d: erwartet ErrUnauthenticated, bekommen %v", len(nonce), err) } } }