internal/apiserver: Server.Handle(version, pattern, h) registriert Routen
unter /api/{version}/... (Akzeptanzkriterium 1) — verschiedene Versionen
sind unabhaengige Pfade im ServeMux, eine neue Version beeintraechtigt
bestehende nicht. HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
Einheitliches Fehlerschema {"error":{"code","message"}} ueber WriteError
(Akzeptanzkriterium 2) — bewusst NICHT auth.RequireAuth aus IAM-02
wiederverwendet, da dessen Klartext-Fehlerantworten nicht zum einheitlichen
JSON-Schema passen wuerden; stattdessen authAndTenantContext nutzt
auth.TokenIssuer.Verify direkt (dieselbe Kryptographie, keine Duplikation)
und antwortet im API-01-Schema, auch bei 401.
authAndTenantContext ist die Middleware, die JEDEM ueber Handle registrierten
Endpunkt Tenant-/Benutzerkontext bereitstellt (Akzeptanzkriterium 3, ueber
apiserver.FromContext abrufbar) — kein Handler prueft Auth selbst.
loggingMiddleware protokolliert jede Anfrage strukturiert.
Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Fehlerhafte Anfrage liefert ueber mehrere Endpunkte hinweg dasselbe
Fehlerschema (Stichprobe) — TestErrorFormat_ConsistentAcrossEndpoints:
zwei unabhaengige Endpunkte, beide liefern 401 im identischen
{"error":{"code","message"}}-Schema. PASS.
2. Middleware-Kette nachweislich von jedem Endpunkt durchlaufen —
TestMiddleware_SetsRequestContextForEveryEndpoint: zwei Endpunkte lesen
RequestContext, beide erhalten korrekten UserID/TenantSlug aus dem Token. PASS.
3. Versionswechsel (fiktive v2-Route) ohne v1 zu beeintraechtigen —
TestVersioning_V2DoesNotAffectV1: v1 vor und nach Anlage von v2 liefert
unveraendert dieselbe Antwort. PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
150 lines
5.1 KiB
Go
150 lines
5.1 KiB
Go
package apiserver
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
|
)
|
|
|
|
func newTestServer() (*Server, *auth.TokenIssuer) {
|
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
|
return NewServer(issuer), issuer
|
|
}
|
|
|
|
func withAuthCookie(req *http.Request, token string) *http.Request {
|
|
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
|
|
return req
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
|
|
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
|
|
srv, issuer := newTestServer()
|
|
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
token, err := issuer.Issue("user-1", "acme")
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
|
|
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
|
|
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
|
|
srv, _ := newTestServer()
|
|
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
|
|
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
|
|
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
|
|
}
|
|
var body errorBody
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
|
|
}
|
|
if body.Error.Code == "" || body.Error.Message == "" {
|
|
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
|
|
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
|
|
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
|
|
srv, issuer := newTestServer()
|
|
|
|
var gotA, gotB RequestContext
|
|
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
|
|
gotA, _ = FromContext(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
|
|
gotB, _ = FromContext(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
token, err := issuer.Issue("user-42", "tenant-x")
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
|
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
|
|
}
|
|
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
|
|
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
|
|
// ohne v1 zu beeintraechtigen.
|
|
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
|
|
srv, issuer := newTestServer()
|
|
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("v1-antwort"))
|
|
})
|
|
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("v2-antwort"))
|
|
})
|
|
|
|
token, err := issuer.Issue("user-1", "acme")
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
|
|
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
|
recV1 := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(recV1, reqV1)
|
|
if recV1.Body.String() != "v1-antwort" {
|
|
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
|
|
}
|
|
|
|
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
|
|
recV2 := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(recV2, reqV2)
|
|
if recV2.Body.String() != "v2-antwort" {
|
|
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
|
|
}
|
|
|
|
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
|
|
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
|
|
recV1Again := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
|
|
if recV1Again.Body.String() != "v1-antwort" {
|
|
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
|
|
srv, _ := newTestServer()
|
|
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
|
|
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
}
|
|
}
|