Root-Modul: apierror und httpharden aus common v0.1.0
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m27s
CI / Web (pdf-viewer) (pull_request) Failing after 1m2s
CI / Web (shl) (pull_request) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 19s
CI / Web (dms-browser) (push) Failing after 1m26s
CI / Web (pdf-viewer) (push) Failing after 1m3s
CI / Web (shl) (push) Failing after 11s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 13m12s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (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
Core-Schnittstellen-Vertragstests / contract-tests (push) Successful in 12m23s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m27s
CI / Web (pdf-viewer) (pull_request) Failing after 1m2s
CI / Web (shl) (pull_request) Failing after 11s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 19s
CI / Web (dms-browser) (push) Failing after 1m26s
CI / Web (pdf-viewer) (push) Failing after 1m3s
CI / Web (shl) (push) Failing after 11s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 13m12s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (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
Core-Schnittstellen-Vertragstests / contract-tests (push) Successful in 12m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
184a219c8a
commit
07eb74cadf
27 files changed
+27
-620
No files matched your search
@@ -17,10 +17,10 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/authtoken"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletoken"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auditadmin"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -5,10 +5,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduledirectory"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
|
||||
+1
-1
@@ -13,8 +13,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/adminapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/metrics"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/notifyapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/notifyprefs"
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/notifyprefs"
|
||||
)
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/policyapi"
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||
)
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/resync"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
|
||||
)
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/statuspage"
|
||||
)
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
toolchain go1.25.13
|
||||
|
||||
require (
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
|
||||
@@ -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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{"archive", "dms", "mail"}
|
||||
|
||||
// TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein
|
||||
// err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror.
|
||||
|
||||
@@ -1,119 +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/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 — jeder der ueber
|
||||
// diesen einen Einstiegspunkt laufenden Core-Dienste bekommt damit
|
||||
// automatisch die Korrelations-ID-Vernebelung von 5xx-Antworten, ohne dass
|
||||
// jede einzelne Aufrufstelle angefasst werden muss.
|
||||
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.
|
||||
// apierror.Normalize puffert NUR Fehlerantworten (Status >= 400) — der
|
||||
// Streaming-Erfolgspfad (grosse Downloads) bleibt unangetastet.
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -210,5 +210,8 @@ func (w *captureWriter) Header() http.Header {
|
||||
}
|
||||
return w.header
|
||||
}
|
||||
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
|
||||
func (w *captureWriter) Write(p []byte) (int, error) {
|
||||
w.body = append(w.body, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
func (w *captureWriter) WriteHeader(statusCode int) {}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
||||
)
|
||||
|
||||
// MAP-03 konsumiert ausschliesslich Approved() -- dieser Test stellt sicher,
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenantmatch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/testdb"
|
||||
)
|
||||
|
||||
func setupSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user