From de90c3d935a242141f257e26af737b06a7ac49e0 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 27 Oct 2026 23:19:13 +0100 Subject: [PATCH] archive: gemeinsame Pakete aus common v0.1.0 statt interner Kopien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apierror, auditclient, httpclient, httpharden, svcauth und tenantregistryclient kommen aus gitea.perlbach24.de/scripte/nexarch/common. Der Wächtertest gegen http.DefaultClient liegt jetzt in internal/archtest. Co-Authored-By: Claude Sonnet 5.5 --- archive/cmd/compliance-api/main.go | 2 +- archive/cmd/kennzahlen-api/main.go | 2 +- archive/cmd/moduleadapter-api/main.go | 4 +- archive/cmd/restoretest-metrics/main.go | 2 +- archive/cmd/retention-api/main.go | 2 +- archive/cmd/scrub-metrics/main.go | 2 +- archive/docs/TESTSTRATEGIE-ARCHIVE.md | 2 +- archive/go.mod | 5 +- archive/go.sum | 2 + archive/internal/apierror/apierror.go | 191 ------------------ archive/internal/apierror/apierror_test.go | 184 ----------------- .../internal/archtest/defaultclient_test.go | 51 +++++ archive/internal/archtest/errorleak_test.go | 2 +- archive/internal/auditclient/auditclient.go | 70 ------- archive/internal/audittrail/audittrail.go | 2 +- .../internal/audittrail/audittrail_test.go | 2 +- .../internal/ediscovery/ediscovery_test.go | 2 +- .../internal/ediscoveryhttp/ediscoveryhttp.go | 2 +- archive/internal/httpclient/httpclient.go | 29 --- .../internal/httpclient/httpclient_test.go | 99 --------- archive/internal/httpharden/harden.go | 116 ----------- archive/internal/httpharden/harden_test.go | 105 ---------- archive/internal/kekclient/kekclient.go | 2 +- archive/internal/legalholdapi/legalholdapi.go | 2 +- archive/internal/moduleadapter/handler.go | 2 +- archive/internal/notifyclient/notifyclient.go | 2 +- archive/internal/pflichttestgate/gate.go | 1 - archive/internal/pflichttestgate/gate_test.go | 1 - archive/internal/rbacclient/rbacclient.go | 2 +- archive/internal/svcauth/svcauth.go | 18 -- archive/internal/svcauth/svcauth_test.go | 23 --- .../tenantregistryclient.go | 60 ------ .../tenantregistryclient_test.go | 59 ------ 33 files changed, 75 insertions(+), 975 deletions(-) delete mode 100644 archive/internal/apierror/apierror.go delete mode 100644 archive/internal/apierror/apierror_test.go create mode 100644 archive/internal/archtest/defaultclient_test.go delete mode 100644 archive/internal/auditclient/auditclient.go delete mode 100644 archive/internal/httpclient/httpclient.go delete mode 100644 archive/internal/httpclient/httpclient_test.go delete mode 100644 archive/internal/httpharden/harden.go delete mode 100644 archive/internal/httpharden/harden_test.go delete mode 100644 archive/internal/svcauth/svcauth.go delete mode 100644 archive/internal/svcauth/svcauth_test.go delete mode 100644 archive/internal/tenantregistryclient/tenantregistryclient.go delete mode 100644 archive/internal/tenantregistryclient/tenantregistryclient_test.go diff --git a/archive/cmd/compliance-api/main.go b/archive/cmd/compliance-api/main.go index 8247114b..f3d2fec2 100644 --- a/archive/cmd/compliance-api/main.go +++ b/archive/cmd/compliance-api/main.go @@ -12,9 +12,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/archive/internal/complianceapi" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient" "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" ) func main() { diff --git a/archive/cmd/kennzahlen-api/main.go b/archive/cmd/kennzahlen-api/main.go index cdd45713..e7241156 100644 --- a/archive/cmd/kennzahlen-api/main.go +++ b/archive/cmd/kennzahlen-api/main.go @@ -10,10 +10,10 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/kennzahlenapi" "gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient" "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" ) func main() { diff --git a/archive/cmd/moduleadapter-api/main.go b/archive/cmd/moduleadapter-api/main.go index 8071d76d..d8a87966 100644 --- a/archive/cmd/moduleadapter-api/main.go +++ b/archive/cmd/moduleadapter-api/main.go @@ -13,10 +13,10 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/legalholdapi" "gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantregistryclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" + "gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient" ) func main() { diff --git a/archive/cmd/restoretest-metrics/main.go b/archive/cmd/restoretest-metrics/main.go index 71d54249..5fdeede7 100644 --- a/archive/cmd/restoretest-metrics/main.go +++ b/archive/cmd/restoretest-metrics/main.go @@ -10,8 +10,8 @@ import ( "net/http" "os" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/restoretest" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" ) func main() { diff --git a/archive/cmd/retention-api/main.go b/archive/cmd/retention-api/main.go index 23824942..1f083a3a 100644 --- a/archive/cmd/retention-api/main.go +++ b/archive/cmd/retention-api/main.go @@ -13,9 +13,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/rbacclient" "gitea.perlbach24.de/scripte/nexarch/archive/internal/retentionapi" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" ) func main() { diff --git a/archive/cmd/scrub-metrics/main.go b/archive/cmd/scrub-metrics/main.go index dae7e080..da084012 100644 --- a/archive/cmd/scrub-metrics/main.go +++ b/archive/cmd/scrub-metrics/main.go @@ -14,8 +14,8 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpharden" "gitea.perlbach24.de/scripte/nexarch/archive/internal/scrub" + "gitea.perlbach24.de/scripte/nexarch/common/httpharden" ) func main() { diff --git a/archive/docs/TESTSTRATEGIE-ARCHIVE.md b/archive/docs/TESTSTRATEGIE-ARCHIVE.md index 35943fb9..d19ba0bf 100644 --- a/archive/docs/TESTSTRATEGIE-ARCHIVE.md +++ b/archive/docs/TESTSTRATEGIE-ARCHIVE.md @@ -30,7 +30,7 @@ Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche - **WORM-Durchsetzung** (`archive/internal/wormstore/`) — die Storage-Object-Lock-Garantie selbst. - **Zugriffsprüfung** (`archive/internal/rbacclient/`, `archive/internal/retentionapi/`) — RBAC-Anbindung und die APIs, die sie durchsetzen. -- **Tenant-/Schlüssel-Bezug** (`archive/internal/tenantregistryclient/`, `archive/internal/kekclient/`, +- **Tenant-/Schlüssel-Bezug** (`archive/internal/kekclient/`, `archive/internal/backupcrypto/`) — unabhängige Tenant-Validierung und Verschlüsselungspfade. Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen diff --git a/archive/go.mod b/archive/go.mod index c4ba8374..e4a5a238 100644 --- a/archive/go.mod +++ b/archive/go.mod @@ -4,7 +4,10 @@ go 1.25.0 toolchain go1.25.13 -require github.com/jackc/pgx/v5 v5.9.2 +require ( + gitea.perlbach24.de/scripte/nexarch/common v0.1.0 + github.com/jackc/pgx/v5 v5.9.2 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/archive/go.sum b/archive/go.sum index 329507a0..2f104b40 100644 --- a/archive/go.sum +++ b/archive/go.sum @@ -1,3 +1,5 @@ +gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY= +gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/archive/internal/apierror/apierror.go b/archive/internal/apierror/apierror.go deleted file mode 100644 index 4872e81d..00000000 --- a/archive/internal/apierror/apierror.go +++ /dev/null @@ -1,191 +0,0 @@ -// Package apierror erzwingt das Core-Fehlerformat (API-01) auf allen -// HTTP-Antworten und schliesst QA-15 F-14: 116 Stellen in Core, DMS, Archive -// und Mail betteten err.Error() direkt in Antworten ein, darunter 500er mit -// Datenbank- und Dateipfadtexten. Statt jede einzelne Aufrufstelle -// anzufassen, schreibt Normalize JEDE Fehlerantwort am aeussersten Rand -// (httpharden.ListenAndServe) um: 4xx-Meldungen bleiben inhaltlich -// erhalten (fachlich, keine Interna), 5xx-Antworten werden PRINZIPIELL -// durch eine allgemeine Meldung mit Korrelations-ID ersetzt — der -// urspruengliche Text (der die Interna enthalten haben KANN) geht nur ins -// Log, niemals in die Antwort. Erweitert dasselbe Grundmuster wie -// dms/internal/apierror (INT-01, dort das "Vorbild" laut API-20). -package apierror - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "log" - "net/http" - "strings" -) - -func CodeForStatus(status int) string { - switch status { - case http.StatusBadRequest: - return "invalid_request" - case http.StatusUnauthorized: - return "unauthenticated" - case http.StatusForbidden: - return "forbidden" - case http.StatusNotFound: - return "not_found" - case http.StatusMethodNotAllowed: - return "method_not_allowed" - case http.StatusConflict: - return "conflict" - case http.StatusGone: - return "gone" - case http.StatusRequestEntityTooLarge: - return "payload_too_large" - case http.StatusUnsupportedMediaType: - return "unsupported_media_type" - case http.StatusUnprocessableEntity: - return "unprocessable" - case http.StatusLocked: - return "locked" - case http.StatusTooManyRequests: - return "rate_limited" - case http.StatusServiceUnavailable: - return "unavailable" - } - if status >= 500 { - return "internal_error" - } - return "invalid_request" -} - -type body struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - CorrelationID string `json:"correlation_id,omitempty"` - } `json:"error"` -} - -func Write(w http.ResponseWriter, status int, code, message string) { - var b body - b.Error.Code = code - b.Error.Message = message - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(b) -} - -func newCorrelationID() string { - buf := make([]byte, 8) - if _, err := rand.Read(buf); err != nil { - // crypto/rand-Ausfall ist praktisch nie beobachtbar; eine feste - // Ersatz-ID ist besser als ein Absturz in einem Fehlerpfad, der - // selbst schon einen Fehler meldet. - return "korrelations-id-nicht-verfuegbar" - } - return hex.EncodeToString(buf) -} - -// Normalize schreibt jede Fehlerantwort (Status >= 400) im Core-Format um. -// 4xx: der vom Handler geschriebene Text bleibt als "message" erhalten (er -// ist fachlich, kein Interna-Leck). 5xx (Akzeptanzkriterium 1): der Text -// wird verworfen, NIE an den Aufrufer weitergegeben — stattdessen eine -// allgemeine Meldung mit Korrelations-ID; derselbe Text landet mitsamt -// Korrelations-ID im Server-Log (Prozess-stderr, wie ueberall sonst in -// diesem Projekt). -func Normalize(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec := &recorder{ResponseWriter: w} - next.ServeHTTP(rec, r) - rec.finish(r) - }) -} - -type recorder struct { - http.ResponseWriter - status int - buffered bool - buf bytes.Buffer -} - -func (r *recorder) WriteHeader(status int) { - if r.status != 0 { - return - } - r.status = status - if status >= 400 { - r.buffered = true - return - } - r.ResponseWriter.WriteHeader(status) -} - -func (r *recorder) Write(p []byte) (int, error) { - if r.status == 0 { - r.WriteHeader(http.StatusOK) - } - if r.buffered { - return r.buf.Write(p) - } - return r.ResponseWriter.Write(p) -} - -func (r *recorder) Flush() { - if f, ok := r.ResponseWriter.(http.Flusher); ok && !r.buffered { - f.Flush() - } -} - -func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } - -func (r *recorder) finish(req *http.Request) { - if !r.buffered { - return - } - code := CodeForStatus(r.status) - rawMessage := strings.TrimSpace(r.buf.String()) - - if r.status >= 500 { - corrID := newCorrelationID() - log.Printf("apierror: 5xx %s %s status=%d korrelations-id=%s ursprung=%q", req.Method, req.URL.Path, r.status, corrID, rawMessage) - out := body{} - out.Error.Code = code - out.Error.Message = "Interner Fehler" - out.Error.CorrelationID = corrID - encoded, _ := json.Marshal(out) - r.flush(append(encoded, '\n')) - return - } - - out := map[string]any{} - message := rawMessage - var parsed map[string]any - if json.Unmarshal(r.buf.Bytes(), &parsed) == nil && parsed != nil { - switch e := parsed["error"].(type) { - case string: - message = e - delete(parsed, "error") - out = parsed - case map[string]any: - if c, _ := e["code"].(string); c != "" { - if m, _ := e["message"].(string); m != "" { - r.flush(r.buf.Bytes()) - return - } - } - } - } - if message == "" { - message = http.StatusText(r.status) - } - out["error"] = map[string]string{"code": code, "message": message} - encoded, _ := json.Marshal(out) - r.flush(append(encoded, '\n')) -} - -func (r *recorder) flush(payload []byte) { - h := r.ResponseWriter.Header() - h.Set("Content-Type", "application/json") - h.Del("Content-Length") - h.Set("X-Content-Type-Options", "nosniff") - r.ResponseWriter.WriteHeader(r.status) - _, _ = r.ResponseWriter.Write(payload) -} diff --git a/archive/internal/apierror/apierror_test.go b/archive/internal/apierror/apierror_test.go deleted file mode 100644 index e26d1cdb..00000000 --- a/archive/internal/apierror/apierror_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package apierror - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { - t.Fatalf("keine json-antwort: %q (%v)", rec.Body.String(), err) - } - return m -} - -func errOf(t *testing.T, m map[string]any) map[string]any { - t.Helper() - e, ok := m["error"].(map[string]any) - if !ok { - t.Fatalf("error ist kein objekt: %v", m) - } - return e -} - -// Akzeptanzkriterium 2: fachliche 4xx-Meldungen bleiben verstaendlich. -func TestNormalize_4xxKeepsHandlerMessage(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - status int - wantCode string - wantMsg string - }{ - {"http.Error klartext", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "dokument nicht gefunden", 404) }, 404, "not_found", "dokument nicht gefunden"}, - {"json string", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(422) - _, _ = w.Write([]byte(`{"error":"ungueltiger wert"}`)) - }, 422, "unprocessable", "ungueltiger wert"}, - {"leerer body", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) }, 403, "forbidden", "Forbidden"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != c.status { - t.Fatalf("status = %d", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { - t.Errorf("content-type = %q", ct) - } - e := errOf(t, decode(t, rec)) - if e["code"] != c.wantCode || e["message"] != c.wantMsg { - t.Errorf("code=%v msg=%v, erwartet %q/%q", e["code"], e["message"], c.wantCode, c.wantMsg) - } - if _, has := e["correlation_id"]; has { - t.Errorf("4xx sollte keine korrelations-id tragen: %v", e) - } - }) - } -} - -// Akzeptanzkriterium 1: 5xx-Antworten enthalten NIE den Handler-Text -// (der die Interna leaken kann), nur eine allgemeine Meldung mit -// Korrelations-ID — egal, was der Handler geschrieben hat. -func TestNormalize_5xxNeverLeaksHandlerText(t *testing.T) { - cases := []struct { - name string - handler http.HandlerFunc - }{ - {"http.Error mit db-fehler", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `pq: connection to server at "10.0.0.5", port 5432 failed`, 500) - }}, - {"http.Error mit dateipfad", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "open /var/lib/nexarch/secrets/tenant.key: permission denied", 502) - }}, - {"json mit interna", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(503) - _, _ = w.Write([]byte(`{"error":"dial tcp 127.0.0.1:5432: connect: connection refused"}`)) - }}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(c.handler).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - body := rec.Body.String() - for _, leak := range []string{"pq:", "10.0.0.5", "/var/lib/nexarch", "permission denied", "127.0.0.1", "dial tcp"} { - if strings.Contains(body, leak) { - t.Errorf("5xx-antwort enthaelt interna %q: %s", leak, body) - } - } - e := errOf(t, decode(t, rec)) - if e["message"] != "Interner Fehler" { - t.Errorf("message = %v, erwartet generische meldung", e["message"]) - } - corrID, _ := e["correlation_id"].(string) - if corrID == "" { - t.Fatalf("keine korrelations-id in der antwort: %v", e) - } - if len(corrID) < 8 { - t.Errorf("korrelations-id zu kurz/vorhersagbar: %q", corrID) - } - }) - } -} - -// Zwei Anfragen mit demselben 5xx-Fehler bekommen unterschiedliche -// Korrelations-IDs (sonst waere die ID im Log nicht eindeutig zuordenbar). -func TestNormalize_5xxCorrelationIDsAreUnique(t *testing.T) { - handler := func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", 500) } - var ids []string - for i := 0; i < 5; i++ { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(handler)).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - id, _ := e["correlation_id"].(string) - for _, seen := range ids { - if seen == id { - t.Fatalf("korrelations-id doppelt vergeben: %q", id) - } - } - ids = append(ids, id) - } -} - -func TestNormalize_KeepsExtraFieldsOn4xx(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(409) - _, _ = w.Write([]byte(`{"error":"duplikat","documentId":"d1"}`)) - })).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil)) - m := decode(t, rec) - if m["documentId"] != "d1" { - t.Errorf("zusatzfeld verloren: %v", m) - } - if errOf(t, m)["code"] != "conflict" { - t.Errorf("code = %v", errOf(t, m)["code"]) - } - - rec = httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Write(w, 401, "unauthenticated", "nicht angemeldet") - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - e := errOf(t, decode(t, rec)) - if e["code"] != "unauthenticated" || e["message"] != "nicht angemeldet" { - t.Errorf("core-format veraendert: %v", e) - } -} - -func TestNormalize_SuccessPassesThroughUnbuffered(t *testing.T) { - rec := httptest.NewRecorder() - Normalize(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write([]byte("%PDF-1.7")) - })).ServeHTTP(rec, httptest.NewRequest("GET", "/x", nil)) - if rec.Code != 200 || rec.Body.String() != "%PDF-1.7" || rec.Header().Get("Content-Type") != "application/pdf" { - t.Errorf("erfolgsantwort veraendert: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type")) - } -} - -func TestNormalize_MuxNotFoundAndMethodNotAllowed(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("GET /a", func(w http.ResponseWriter, r *http.Request) {}) - h := Normalize(mux) - for _, c := range []struct { - method, path string - status int - code string - }{{"GET", "/nope", 404, "not_found"}, {"POST", "/a", 405, "method_not_allowed"}} { - rec := httptest.NewRecorder() - h.ServeHTTP(rec, httptest.NewRequest(c.method, c.path, nil)) - if rec.Code != c.status { - t.Fatalf("%s %s: status %d", c.method, c.path, rec.Code) - } - if errOf(t, decode(t, rec))["code"] != c.code { - t.Errorf("%s %s: code %v", c.method, c.path, errOf(t, decode(t, rec))["code"]) - } - } -} diff --git a/archive/internal/archtest/defaultclient_test.go b/archive/internal/archtest/defaultclient_test.go new file mode 100644 index 00000000..92c29555 --- /dev/null +++ b/archive/internal/archtest/defaultclient_test.go @@ -0,0 +1,51 @@ +package archtest + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber +// den Standard-Client ohne Zeitlimit. +func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`) + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + for i, line := range strings.Split(string(data), "\n") { + if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") { + continue + } + if forbidden.MatchString(line) { + rel, _ := filepath.Rel(root, path) + t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/archive/internal/archtest/errorleak_test.go b/archive/internal/archtest/errorleak_test.go index c814bc57..4869ca95 100644 --- a/archive/internal/archtest/errorleak_test.go +++ b/archive/internal/archtest/errorleak_test.go @@ -33,7 +33,7 @@ func moduleRoot(t *testing.T) string { // Antwort bzw. der rohe ListenAndServe-Aufruf genau die erlaubte // Implementierung der Regel selbst ist (API-20/API-21), kein Verstoss. // Das Archive-Modul hat keine weiteren, physisch verschachtelten Go-Module. -var exempt = []string{"internal/apierror", "internal/httpharden"} +var exempt = []string{} // TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein // err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror. diff --git a/archive/internal/auditclient/auditclient.go b/archive/internal/auditclient/auditclient.go deleted file mode 100644 index ad04469a..00000000 --- a/archive/internal/auditclient/auditclient.go +++ /dev/null @@ -1,70 +0,0 @@ -// Package auditclient ist ein schlanker HTTP-Client für Core AUD-08 -// (internal/audit, POST /audit/ingest). Archive ist ein physisch -// getrenntes Go-Modul und kann Cores internal/audit daher nicht direkt -// importieren — nur über dessen HTTP-Schnittstelle. Gleiches Muster wie -// archive/internal/notifyclient (CFG-05). -package auditclient - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" - "net/http" -) - -type Client struct { - BaseURL string - ServiceToken string - HTTPClient *http.Client -} - -func New(baseURL, serviceToken string) *Client { - return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpclient.Default()} -} - -type ingestRequest struct { - TenantSlug string `json:"tenant_slug"` - Actor string `json:"actor"` - Action string `json:"action"` - Target string `json:"target"` - Metadata map[string]any `json:"metadata"` -} - -// Ingest meldet EIN Audit-Ereignis an Core. Jeder Fehler (Transport, -// Timeout, unerwarteter Status) wird als Fehler zurückgegeben — der -// Aufrufer entscheidet, ob/wie er das nachbehandelt (siehe -// archive/internal/audittrail für den outage-toleranten Wrapper). -func (c *Client) Ingest(ctx context.Context, tenantSlug, actor, action, target string, metadata map[string]any) error { - body, err := json.Marshal(ingestRequest{ - TenantSlug: tenantSlug, Actor: actor, Action: action, Target: target, Metadata: metadata, - }) - if err != nil { - return fmt.Errorf("auditclient: request kodieren: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/audit/ingest", bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("auditclient: request bauen: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Service-Token", c.ServiceToken) - - resp, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("auditclient: aufruf fehlgeschlagen: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusAccepted { - return fmt.Errorf("auditclient: unerwarteter status %d", resp.StatusCode) - } - return nil -} - -func (c *Client) httpClient() *http.Client { - if c.HTTPClient != nil { - return c.HTTPClient - } - return httpclient.Default() -} diff --git a/archive/internal/audittrail/audittrail.go b/archive/internal/audittrail/audittrail.go index 61fc31e7..4a13b654 100644 --- a/archive/internal/audittrail/audittrail.go +++ b/archive/internal/audittrail/audittrail.go @@ -20,7 +20,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/auditclient" + "gitea.perlbach24.de/scripte/nexarch/common/auditclient" ) type Recorder struct { diff --git a/archive/internal/audittrail/audittrail_test.go b/archive/internal/audittrail/audittrail_test.go index f51d3928..8de427ee 100644 --- a/archive/internal/audittrail/audittrail_test.go +++ b/archive/internal/audittrail/audittrail_test.go @@ -11,7 +11,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/auditclient" + "gitea.perlbach24.de/scripte/nexarch/common/auditclient" ) func setupOutboxTest(t *testing.T) *pgxpool.Pool { diff --git a/archive/internal/ediscovery/ediscovery_test.go b/archive/internal/ediscovery/ediscovery_test.go index 4a18b467..2975eb96 100644 --- a/archive/internal/ediscovery/ediscovery_test.go +++ b/archive/internal/ediscovery/ediscovery_test.go @@ -13,9 +13,9 @@ import ( "strings" "testing" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/auditclient" "gitea.perlbach24.de/scripte/nexarch/archive/internal/audittrail" "gitea.perlbach24.de/scripte/nexarch/archive/internal/referenceset" + "gitea.perlbach24.de/scripte/nexarch/common/auditclient" "github.com/jackc/pgx/v5/pgxpool" ) diff --git a/archive/internal/ediscoveryhttp/ediscoveryhttp.go b/archive/internal/ediscoveryhttp/ediscoveryhttp.go index 1a1b32cb..14eb8c83 100644 --- a/archive/internal/ediscoveryhttp/ediscoveryhttp.go +++ b/archive/internal/ediscoveryhttp/ediscoveryhttp.go @@ -14,7 +14,7 @@ import ( "net/url" "gitea.perlbach24.de/scripte/nexarch/archive/internal/ediscovery" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" ) const ModuleTypeDMSDocument = "dms_document" diff --git a/archive/internal/httpclient/httpclient.go b/archive/internal/httpclient/httpclient.go deleted file mode 100644 index 53c9a9f4..00000000 --- a/archive/internal/httpclient/httpclient.go +++ /dev/null @@ -1,29 +0,0 @@ -// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe -// (QA-15 F-11): Zeitlimit statt endlosem Warten auf einen haengenden -// Nachbardienst, und keine Weiterleitungen (ein Service-Token wuerde sonst an -// das Weiterleitungsziel mitgesendet). -package httpclient - -import ( - "net/http" - "time" -) - -// DefaultTimeout gilt fuer einen kompletten Aufruf (Verbindung bis Antwortende). -const DefaultTimeout = 15 * time.Second - -var shared = New(DefaultTimeout) - -// Default ist der gemeinsame Client mit DefaultTimeout. -func Default() *http.Client { return shared } - -// New liefert einen Client mit dem angegebenen Zeitlimit, der Weiterleitungen -// nicht folgt (die 3xx-Antwort wird zurueckgegeben). -func New(timeout time.Duration) *http.Client { - return &http.Client{ - Timeout: timeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - }, - } -} diff --git a/archive/internal/httpclient/httpclient_test.go b/archive/internal/httpclient/httpclient_test.go deleted file mode 100644 index ea24c6b2..00000000 --- a/archive/internal/httpclient/httpclient_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package httpclient - -import ( - "io/fs" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "regexp" - "strings" - "testing" - "time" -) - -func TestDefaultHasTimeout(t *testing.T) { - if Default().Timeout != DefaultTimeout || DefaultTimeout <= 0 { - t.Fatalf("timeout = %s", Default().Timeout) - } -} - -func TestClientGivesUpOnHangingServer(t *testing.T) { - release := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release })) - defer srv.Close() - defer close(release) - - start := time.Now() - resp, err := New(200 * time.Millisecond).Get(srv.URL) - if err == nil { - _ = resp.Body.Close() - t.Fatal("kein fehler bei haengendem server") - } - if elapsed := time.Since(start); elapsed > 5*time.Second { - t.Fatalf("aufruf dauerte %s", elapsed) - } -} - -func TestClientDoesNotFollowRedirects(t *testing.T) { - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Error("weiterleitungsziel wurde aufgerufen (Service-Token-Leck)") - })) - defer target.Close() - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) - })) - defer redirector.Close() - - req, _ := http.NewRequest(http.MethodPost, redirector.URL, strings.NewReader("x")) - req.Header.Set("X-Service-Token", "geheim") - resp, err := Default().Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusTemporaryRedirect { - t.Fatalf("status = %d, erwartet 307", resp.StatusCode) - } -} - -// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber -// den Standard-Client ohne Zeitlimit. -func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) { - root, err := filepath.Abs(filepath.Join("..", "..")) - if err != nil { - t.Fatal(err) - } - forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`) - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - data, err := os.ReadFile(path) - if err != nil { - return err - } - for i, line := range strings.Split(string(data), "\n") { - if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") { - continue - } - if forbidden.MatchString(line) { - rel, _ := filepath.Rel(root, path) - t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line)) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/archive/internal/httpharden/harden.go b/archive/internal/httpharden/harden.go deleted file mode 100644 index 32d8f8df..00000000 --- a/archive/internal/httpharden/harden.go +++ /dev/null @@ -1,116 +0,0 @@ -// Package httpharden liefert gehaertete http.Server-Grundeinstellungen -// (Timeouts, Header-Limit) und ein Body-Limit fuer alle Dienste (QA-15 F-05). -// Dieselbe Datei existiert in archive/internal/httpharden und -// dms/internal/httpharden, weil die Module getrennte go.mod-Dateien haben. -package httpharden - -import ( - "log" - "net/http" - "os" - "strconv" - "time" - - "gitea.perlbach24.de/scripte/nexarch/archive/internal/apierror" -) - -const ( - DefaultReadHeaderTimeout = 10 * time.Second - DefaultReadTimeout = 30 * time.Second - DefaultWriteTimeout = 60 * time.Second - DefaultIdleTimeout = 120 * time.Second - DefaultMaxHeaderBytes = 1 << 20 - DefaultMaxBodyBytes = 1 << 20 - - EnvReadHeaderTimeout = "NEXARCH_HTTP_READ_HEADER_TIMEOUT_SECONDS" - EnvReadTimeout = "NEXARCH_HTTP_READ_TIMEOUT_SECONDS" - EnvWriteTimeout = "NEXARCH_HTTP_WRITE_TIMEOUT_SECONDS" - EnvIdleTimeout = "NEXARCH_HTTP_IDLE_TIMEOUT_SECONDS" - EnvMaxBodyBytes = "NEXARCH_HTTP_MAX_BODY_BYTES" -) - -// Options weicht vom Standard ab. Nullwerte bedeuten Standard bzw. Env-Wert; -// StreamingBodies/StreamingResponses setzen ReadTimeout/WriteTimeout auf 0 -// (unbegrenzt), damit lange Uploads und Downloads nicht abbrechen. Schutz -// gegen langsame Header bleibt ueber ReadHeaderTimeout und IdleTimeout. -type Options struct { - StreamingBodies bool - StreamingResponses bool -} - -func envSeconds(name string, def time.Duration) time.Duration { - v := os.Getenv(name) - if v == "" { - return def - } - n, err := strconv.Atoi(v) - if err != nil || n < 1 || n > 3600 { - log.Fatalf("%s muss eine ganze Zahl zwischen 1 und 3600 sein, war %q", name, v) - } - return time.Duration(n) * time.Second -} - -// MaxBodyBytes liest die Body-Obergrenze (Standard 1 MiB, Env -// NEXARCH_HTTP_MAX_BODY_BYTES, 1 KiB bis 1 GiB). -func MaxBodyBytes() int64 { - v := os.Getenv(EnvMaxBodyBytes) - if v == "" { - return DefaultMaxBodyBytes - } - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n < 1<<10 || n > 1<<30 { - log.Fatalf("%s muss zwischen 1024 und 1073741824 liegen, war %q", EnvMaxBodyBytes, v) - } - return n -} - -// NewServer baut einen http.Server mit gehaerteten Timeouts. -func NewServer(addr string, h http.Handler, opt Options) *http.Server { - s := &http.Server{ - Addr: addr, - Handler: h, - ReadHeaderTimeout: envSeconds(EnvReadHeaderTimeout, DefaultReadHeaderTimeout), - ReadTimeout: envSeconds(EnvReadTimeout, DefaultReadTimeout), - WriteTimeout: envSeconds(EnvWriteTimeout, DefaultWriteTimeout), - IdleTimeout: envSeconds(EnvIdleTimeout, DefaultIdleTimeout), - MaxHeaderBytes: DefaultMaxHeaderBytes, - } - if opt.StreamingBodies { - s.ReadTimeout = 0 - } - if opt.StreamingResponses { - s.WriteTimeout = 0 - } - return s -} - -// ListenAndServe ersetzt http.ListenAndServe: JSON-API mit Body-Limit und -// (QA-15 F-14) apierror.Normalize aussen um jeden Handler — 5xx-Antworten -// verlieren dadurch automatisch jeden Interna-Text (Datenbank-/Pfadfehler), -// unabhaengig davon, was der einzelne Handler geschrieben hat. -func ListenAndServe(addr string, h http.Handler) error { - return NewServer(addr, apierror.Normalize(LimitBody(MaxBodyBytes(), h)), Options{}).ListenAndServe() -} - -// ListenAndServeStreaming fuer Dienste mit grossen Uploads/Downloads: keine -// globale Body-Grenze und keine Lese-/Schreib-Gesamtzeit, nur Header-Timeout. -func ListenAndServeStreaming(addr string, h http.Handler) error { - return NewServer(addr, apierror.Normalize(h), Options{StreamingBodies: true, StreamingResponses: true}).ListenAndServe() -} - -// LimitBody begrenzt Request-Bodies. Ein Content-Length ueber dem Limit wird -// vor dem Handler mit 413 abgewiesen, chunked Bodies ueber MaxBytesReader. -func LimitBody(max int64, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.ContentLength > max { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusRequestEntityTooLarge) - _, _ = w.Write([]byte(`{"error":{"code":"payload_too_large","message":"Anfrage zu gross"}}`)) - return - } - if r.Body != nil && r.Body != http.NoBody { - r.Body = http.MaxBytesReader(w, r.Body, max) - } - next.ServeHTTP(w, r) - }) -} diff --git a/archive/internal/httpharden/harden_test.go b/archive/internal/httpharden/harden_test.go deleted file mode 100644 index 4edd2481..00000000 --- a/archive/internal/httpharden/harden_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package httpharden - -import ( - "bufio" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestLimitBody_ContentLengthOverLimitIs413(t *testing.T) { - called := false - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) - req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 11))) - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusRequestEntityTooLarge || called { - t.Fatalf("code=%d called=%v", rec.Code, called) - } -} - -func TestLimitBody_ChunkedOverLimitFailsRead(t *testing.T) { - var readErr error - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, readErr = io.ReadAll(r.Body) - })) - req := httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("a", 50))) - req.ContentLength = -1 - h.ServeHTTP(httptest.NewRecorder(), req) - if readErr == nil { - t.Fatal("lesen ueber dem Limit muss fehlschlagen") - } -} - -func TestLimitBody_UnderLimitPasses(t *testing.T) { - var got string - h := LimitBody(10, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - b, _ := io.ReadAll(r.Body) - got = string(b) - })) - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/", strings.NewReader("abc"))) - if got != "abc" { - t.Fatalf("got %q", got) - } -} - -func TestNewServer_DefaultsAndStreaming(t *testing.T) { - s := NewServer(":0", nil, Options{}) - if s.ReadHeaderTimeout == 0 || s.ReadTimeout == 0 || s.WriteTimeout == 0 || s.IdleTimeout == 0 { - t.Fatalf("Timeouts muessen gesetzt sein: %+v", s) - } - st := NewServer(":0", nil, Options{StreamingBodies: true, StreamingResponses: true}) - if st.ReadTimeout != 0 || st.WriteTimeout != 0 || st.ReadHeaderTimeout == 0 || st.IdleTimeout == 0 { - t.Fatalf("Streaming: nur Lese-/Schreib-Gesamtzeit 0: %+v", st) - } -} - -func TestNewServer_EnvOverride(t *testing.T) { - t.Setenv(EnvReadHeaderTimeout, "3") - if got := NewServer(":0", nil, Options{}).ReadHeaderTimeout; got != 3*time.Second { - t.Fatalf("got %v", got) - } -} - -func TestMaxBodyBytes_EnvOverride(t *testing.T) { - if MaxBodyBytes() != DefaultMaxBodyBytes { - t.Fatal("Standard") - } - t.Setenv(EnvMaxBodyBytes, "2048") - if MaxBodyBytes() != 2048 { - t.Fatal("Env") - } -} - -// Slowloris: Verbindung sendet unvollstaendige Header, der Server trennt nach ReadHeaderTimeout. -func TestSlowHeadersAreClosed(t *testing.T) { - t.Setenv(EnvReadHeaderTimeout, "1") - srv := NewServer("127.0.0.1:0", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), Options{}) - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - go srv.Serve(ln) - defer srv.Close() - conn, err := net.Dial("tcp", ln.Addr().String()) - if err != nil { - t.Fatal(err) - } - defer conn.Close() - if _, err := conn.Write([]byte("GET / HTTP/1.1\r\nHost: x\r\nX-Slow: a")); err != nil { - t.Fatal(err) - } - conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - start := time.Now() - _, err = bufio.NewReader(conn).ReadByte() - if err == nil { - t.Log("Server antwortete (z. B. 408)") - } - if time.Since(start) > 4*time.Second { - t.Fatalf("Verbindung wurde nicht nach dem Header-Timeout beendet (%v)", time.Since(start)) - } -} diff --git a/archive/internal/kekclient/kekclient.go b/archive/internal/kekclient/kekclient.go index 275a18ab..6d7e644d 100644 --- a/archive/internal/kekclient/kekclient.go +++ b/archive/internal/kekclient/kekclient.go @@ -11,7 +11,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" "net/url" ) diff --git a/archive/internal/legalholdapi/legalholdapi.go b/archive/internal/legalholdapi/legalholdapi.go index 7c7b5e77..dec8d8c7 100644 --- a/archive/internal/legalholdapi/legalholdapi.go +++ b/archive/internal/legalholdapi/legalholdapi.go @@ -12,7 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/svcauth" + "gitea.perlbach24.de/scripte/nexarch/common/svcauth" ) const ( diff --git a/archive/internal/moduleadapter/handler.go b/archive/internal/moduleadapter/handler.go index 9fad7550..ad978797 100644 --- a/archive/internal/moduleadapter/handler.go +++ b/archive/internal/moduleadapter/handler.go @@ -7,7 +7,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/svcauth" + "gitea.perlbach24.de/scripte/nexarch/common/svcauth" ) const ( diff --git a/archive/internal/notifyclient/notifyclient.go b/archive/internal/notifyclient/notifyclient.go index 18d94551..aa87c8b0 100644 --- a/archive/internal/notifyclient/notifyclient.go +++ b/archive/internal/notifyclient/notifyclient.go @@ -10,7 +10,7 @@ import ( "context" "encoding/json" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/archive/internal/pflichttestgate/gate.go b/archive/internal/pflichttestgate/gate.go index 2ec64a95..8f87076b 100644 --- a/archive/internal/pflichttestgate/gate.go +++ b/archive/internal/pflichttestgate/gate.go @@ -15,7 +15,6 @@ import ( var sensitivePathPatterns = []*regexp.Regexp{ regexp.MustCompile(`(^|/)archive/internal/wormstore/`), regexp.MustCompile(`(^|/)archive/internal/rbacclient/`), - regexp.MustCompile(`(^|/)archive/internal/tenantregistryclient/`), regexp.MustCompile(`(^|/)archive/internal/retentionapi/`), regexp.MustCompile(`(^|/)archive/internal/kekclient/`), regexp.MustCompile(`(^|/)archive/internal/backupcrypto/`), diff --git a/archive/internal/pflichttestgate/gate_test.go b/archive/internal/pflichttestgate/gate_test.go index 6ff51457..90691a10 100644 --- a/archive/internal/pflichttestgate/gate_test.go +++ b/archive/internal/pflichttestgate/gate_test.go @@ -60,7 +60,6 @@ func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) { cases := []string{ "archive/internal/wormstore/wormstore.go", "archive/internal/rbacclient/rbacclient.go", - "archive/internal/tenantregistryclient/tenantregistryclient.go", "archive/internal/retentionapi/handler.go", "archive/internal/kekclient/kekclient.go", "archive/internal/backupcrypto/reposecret.go", diff --git a/archive/internal/rbacclient/rbacclient.go b/archive/internal/rbacclient/rbacclient.go index cdd51af2..5be075be 100644 --- a/archive/internal/rbacclient/rbacclient.go +++ b/archive/internal/rbacclient/rbacclient.go @@ -11,7 +11,7 @@ import ( "encoding/json" "errors" "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" + "gitea.perlbach24.de/scripte/nexarch/common/httpclient" "net/http" ) diff --git a/archive/internal/svcauth/svcauth.go b/archive/internal/svcauth/svcauth.go deleted file mode 100644 index 4f803353..00000000 --- a/archive/internal/svcauth/svcauth.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package svcauth vergleicht Service-Token in konstanter Zeit (QA-15 F-23). -package svcauth - -import ( - "crypto/sha256" - "crypto/subtle" -) - -// Equal vergleicht die SHA-256-Werte beider Eingaben in konstanter Zeit; anders -// als ein direkter ConstantTimeCompare verraet der Vergleich nicht die Laenge -// des erwarteten Tokens. Ein leeres erwartetes Token stimmt nie. -func Equal(got, want string) bool { - if want == "" { - return false - } - g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want)) - return subtle.ConstantTimeCompare(g[:], w[:]) == 1 -} diff --git a/archive/internal/svcauth/svcauth_test.go b/archive/internal/svcauth/svcauth_test.go deleted file mode 100644 index c06848c9..00000000 --- a/archive/internal/svcauth/svcauth_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package svcauth - -import "testing" - -func TestEqual(t *testing.T) { - cases := []struct { - got, want string - ok bool - }{ - {"geheim", "geheim", true}, - {"geheim ", "geheim", false}, - {"", "geheim", false}, - {"geheim", "", false}, - {"", "", false}, - {"g", "geheim", false}, - {"geheimgeheim", "geheim", false}, - } - for _, c := range cases { - if got := Equal(c.got, c.want); got != c.ok { - t.Errorf("Equal(%q,%q) = %v, erwartet %v", c.got, c.want, got, c.ok) - } - } -} diff --git a/archive/internal/tenantregistryclient/tenantregistryclient.go b/archive/internal/tenantregistryclient/tenantregistryclient.go deleted file mode 100644 index e0875a17..00000000 --- a/archive/internal/tenantregistryclient/tenantregistryclient.go +++ /dev/null @@ -1,60 +0,0 @@ -// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core -// TEN-01 (Tenant-Registry). Archive ist ein physisch getrenntes Go-Modul -// und kann Core TEN-01 daher nicht direkt importieren - nur ueber dessen -// HTTP-Schnittstelle abfragen. Gleiches Muster wie archive/internal/ -// rbacclient (RET-08) und archive/internal/notifyclient (RET-07). -package tenantregistryclient - -import ( - "context" - "fmt" - "gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient" - "net/http" - "net/url" -) - -// Client fragt Core TEN-01 nach der Gueltigkeit eines Tenant-Kontexts. -type Client struct { - BaseURL string - ServiceToken string - HTTPClient *http.Client -} - -func New(baseURL, serviceToken string) *Client { - return &Client{BaseURL: baseURL, ServiceToken: serviceToken} -} - -// VerifyTenant prueft, ob tenantSlug in der Core-Tenant-Registry als -// existierender Tenant gefuehrt wird - unabhaengig davon, was das -// aufrufende Modul (DMS/Mail) selbst behauptet. Ein Transport-/ -// Serverfehler wird als Fehler zurueckgegeben, NICHT stillschweigend als -// gueltig behandelt - der Aufrufer muss fail-closed (ablehnen) reagieren. -func (c *Client) VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/tenants/"+url.PathEscape(tenantSlug), nil) - if err != nil { - return false, fmt.Errorf("tenantregistryclient: request bauen: %w", err) - } - req.Header.Set("X-Service-Token", c.ServiceToken) - - resp, err := c.httpClient().Do(req) - if err != nil { - return false, fmt.Errorf("tenantregistryclient: aufruf fehlgeschlagen: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - switch resp.StatusCode { - case http.StatusOK: - return true, nil - case http.StatusNotFound: - return false, nil - default: - return false, fmt.Errorf("tenantregistryclient: unerwarteter status %d", resp.StatusCode) - } -} - -func (c *Client) httpClient() *http.Client { - if c.HTTPClient != nil { - return c.HTTPClient - } - return httpclient.Default() -} diff --git a/archive/internal/tenantregistryclient/tenantregistryclient_test.go b/archive/internal/tenantregistryclient/tenantregistryclient_test.go deleted file mode 100644 index 41f9f355..00000000 --- a/archive/internal/tenantregistryclient/tenantregistryclient_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package tenantregistryclient - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestVerifyTenant_ExistingTenant(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/tenants/mandant-a" { - t.Errorf("pfad = %q, want /tenants/mandant-a", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() - - c := New(server.URL, "test-token") - ok, err := c.VerifyTenant(context.Background(), "mandant-a") - if err != nil { - t.Fatalf("verifytenant: %v", err) - } - if !ok { - t.Fatal("erwartet gueltigen tenant, habe false") - } -} - -func TestVerifyTenant_UnknownTenant(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer server.Close() - - c := New(server.URL, "test-token") - ok, err := c.VerifyTenant(context.Background(), "unbekannt") - if err != nil { - t.Fatalf("verifytenant: %v", err) - } - if ok { - t.Fatal("erwartet ungueltigen tenant, habe true") - } -} - -func TestVerifyTenant_ServerErrorIsFailClosed(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer server.Close() - - c := New(server.URL, "test-token") - ok, err := c.VerifyTenant(context.Background(), "mandant-a") - if err == nil { - t.Fatal("erwartet fehler bei 500-antwort der tenant-registry") - } - if ok { - t.Fatal("erwartet false bei fehler (fail-closed)") - } -} -- 2.47.3