Compare commits

...
Author SHA1 Message Date
patrickandClaude Sonnet 5.5 cd5ca8da53 refactor: geteiltes Modul common/ (Pilot httpclient, apierror)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
- common/httpclient: bisher byte-identisch in dms, mail und archive
- common/apierror: bisher byte-identisch in Root und archive
- Importpfade, go.mod (require + replace), archtest-Ausnahmen und
  CI-Pfadfilter der mail-/archive-Pflichttest-Gates angepasst
- Waechtertest fuer den Standard-Client prueft weiter dms, mail, archive
- dms/internal/apierror bleibt eigenstaendig (5xx-Fix in eigenem PR)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:52:24 +01:00
42 changed files with 61 additions and 662 deletions

No files matched your search

@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "archive/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "mail/**"
- "common/**"
jobs:
pflichttest-gate:
+2
View File
@@ -64,6 +64,8 @@ Kein Modul kopiert die Daten eines anderen — stattdessen ein wiederkehrendes *
Gemeinsamer Nenner aller drei: **Referenz statt Kopie, Adapter statt Direktkopplung.**
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete werden deshalb im geteilten Modul `common/` gehalten (per `replace` in den `go.mod` eingebunden, keine eigenen Abhängigkeiten außer der Standardbibliothek). Pilot: `common/httpclient`, `common/apierror`. Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
Dasselbe Prinzip gilt auch innerhalb eines Moduls, auf Code-Ebene: Hauptdateien/Entry-Points bleiben klein, Fachlogik wandert in eigene Module. Vor einer neuen Funktions-/Modul-Datei wird geprüft, ob dieselbe Funktionalität nicht schon woanders existiert (auch modulübergreifend, z. B. in Core) — keine doppelten Funktionsdateien. Das eigene Kanban-Planungswerkzeug wurde nach genau diesem Muster korrigiert (sieben `kanban.html` enthielten identisches JS/CSS als Volltext-Duplikat, damals in `_shared/kanban.js`/`kanban.css` ausgelagert) — die Boards selbst sind inzwischen komplett aus dem Repo entfernt (Ticket-Historie liegt in der Gitea-PR-Historie), das Prinzip "keine doppelten Funktionsdateien" gilt unverändert für den eigentlichen NEXARCH-Code.
## 6. Technische Architektur (Kurzfassung, Details in eigenen Dokumenten)
+4
View File
@@ -13,3 +13,7 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
+1 -1
View File
@@ -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{"internal/httpharden"}
// TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein
// err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror.
+1 -1
View File
@@ -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"
)
@@ -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"
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/apierror"
"gitea.perlbach24.de/scripte/nexarch/common/apierror"
)
const (
+1 -1
View File
@@ -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"
)
@@ -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"
)
+1 -1
View File
@@ -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"
)
@@ -8,7 +8,7 @@ package tenantregistryclient
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/archive/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
"net/url"
)
File renamed without changes.
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
module gitea.perlbach24.de/scripte/nexarch/common
go 1.25.0
File renamed without changes.
@@ -57,15 +57,22 @@ func TestClientDoesNotFollowRedirects(t *testing.T) {
}
}
// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber
// den Standard-Client ohne Zeitlimit.
// Architekturtest: im Produktivcode der Module, die dieses Paket nutzen, gibt
// es keine Aufrufe ueber den Standard-Client ohne Zeitlimit.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
repoRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
for _, mod := range []string{"common", "dms", "mail", "archive"} {
scanNoDefaultClient(t, repoRoot, filepath.Join(repoRoot, mod))
}
}
func scanNoDefaultClient(t *testing.T, repoRoot, root string) {
t.Helper()
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
@@ -87,7 +94,7 @@ func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
continue
}
if forbidden.MatchString(line) {
rel, _ := filepath.Rel(root, path)
rel, _ := filepath.Rel(repoRoot, path)
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
}
}
+1 -1
View File
@@ -30,9 +30,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folder"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix"
+1 -1
View File
@@ -23,8 +23,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix"
+4
View File
@@ -52,3 +52,7 @@ require (
golang.org/x/text v0.41.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
"net/url"
)
-29
View File
@@ -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
},
}
}
@@ -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)
}
}
@@ -12,7 +12,7 @@ import (
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
type Client struct {
@@ -13,7 +13,7 @@ import (
"net/http"
"net/url"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/modulecall"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
)
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue"
)
@@ -21,8 +21,8 @@ const (
ModuleName = "dms"
ObjectType = "document"
TriggerCreated = "document.created"
TriggerStatusChanged = "document.status_changed"
TriggerCreated = "document.created"
TriggerStatusChanged = "document.status_changed"
TriggerDeadlineReached = "document.deadline_reached"
JobType = "workflow_event_publish"
+4
View File
@@ -26,3 +26,7 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ./common
-191
View File
@@ -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)
}
-184
View File
@@ -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"])
}
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ func moduleRoot(t *testing.T) string {
// aber eigenstaendigen Go-Module (eigene go.mod, eigene Regeln/Ausnahmen -
// DMS hat mit INT-01/INT-15 bereits ein eigenes Muster, siehe API-21-Ticket
// "Nicht Bestandteil dieser Kachel").
var exempt = []string{"internal/apierror", "internal/httpharden", "archive", "dms", "mail"}
var exempt = []string{"common", "internal/httpharden", "archive", "dms", "mail"}
// TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein
// err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror.
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/apierror"
"gitea.perlbach24.de/scripte/nexarch/common/apierror"
)
const (
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/apierror"
"gitea.perlbach24.de/scripte/nexarch/common/apierror"
"gitea.perlbach24.de/scripte/nexarch/internal/httpclient"
)
@@ -10,7 +10,7 @@ import (
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/apierror"
"gitea.perlbach24.de/scripte/nexarch/common/apierror"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir"
)
+4
View File
@@ -48,3 +48,7 @@ require (
golang.org/x/sys v0.47.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
"net/url"
)
-29
View File
@@ -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
},
}
}
@@ -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)
}
}
@@ -12,7 +12,7 @@ import (
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
const moduleName = "mail"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"io"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
// Config steuert Ziel und Zugangsdaten des Workflow-Modul-Adapters