Compare commits

..
Author SHA1 Message Date
patrickandClaude Sonnet 5.5 7515b8db4d refactor: weitere identische Pakete nach common/ (svcauth, auditclient, tenantregistryclient, policyclient, httpharden, loadtest, httpclient)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (push) 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
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Archive-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
Archive-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Connect-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Workflow-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s
Connect-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Mail-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
Workflow-Pflichttest-Gate / pflichttest-gate (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
Mail-Schnittstellen-Vertragstests / contract-tests (push) 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
Workflow-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Nur Pakete, deren Code ohne Kommentare/Importpfade byte-gleich ist:
- httpclient: ai, connect (Root behaelt eigene Kopie wegen strengerem Waechtertest)
- svcauth: archive, ai, workflow
- auditclient: ai, connect, archive
- tenantregistryclient: dms, ai, workflow (archive weicht ab, bleibt)
- policyclient: mail, connect (dms weicht ab, bleibt)
- httpharden: Root, archive (dms weicht ab, bleibt)
- loadtest: mail, ai (archive, connect weichen ab, bleiben)

go.mod (require + replace) fuer ai, connect, workflow; Pflichttest-Gates
von ai/workflow und CI-Pfadfilter um common/ erweitert, damit die
sicherheitskritischen Pakete (svcauth, tenantregistryclient) weiter
gegated werden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:06:07 +01:00
patrickandClaude Sonnet 5.5 cd5ca8da53 refactor: geteiltes Modul common/ (Pilot httpclient, apierror)
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) 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
Core-Schnittstellen-Vertragstests / contract-tests (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
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
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Mail-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
184 changed files with 281 additions and 4868 deletions

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "ai/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "archive/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -1,23 +0,0 @@
name: common Pflichttest-Gate
on:
pull_request:
paths:
- "common/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Gate bauen
working-directory: common
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- common/ | /tmp/pflichttestgate
-33
View File
@@ -1,33 +0,0 @@
name: common Versions-Tag
on:
push:
branches: [main]
paths:
- "common/**"
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Build, Vet, Test
working-directory: common
run: |
go build ./...
go vet ./...
go test ./... -count=1
- name: Naechsten Tag setzen
run: |
minor=$(tr -d '[:space:]' < common/MINOR)
last=$(git tag -l "common/v0.${minor}.*" --sort=-v:refname | head -n1)
if [ -z "$last" ]; then next="common/v0.${minor}.0"; else
patch=${last##*.}; base=${last%.*}; next="${base}.$((patch + 1))"
fi
git tag "$next"
git push origin "$next"
-28
View File
@@ -1,28 +0,0 @@
name: DB-Integrationstests
on:
pull_request:
paths:
- "common/**"
- "dms/**"
- "mail/**"
- "archive/**"
- "ai/**"
- "connect/**"
- "workflow/**"
- "internal/**"
jobs:
db-tests:
runs-on: nexarch-test
env:
TEST_ADMIN_DSN: ${{ secrets.TEST_ADMIN_DSN }}
REQUIRE_DB: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Tests (common)
working-directory: common
run: go test ./testdb/... ./migrate/... -count=1 -p 1
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "mail/**"
- "common/**"
jobs:
pflichttest-gate:
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "workflow/**"
- "common/**"
jobs:
pflichttest-gate:
+1 -1
View File
@@ -64,7 +64,7 @@ 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 liegen deshalb im versionierten Modul `common/` (Git-Tags `common/vX.Y.Z`, siehe `common/README.md`). So bleiben Module einzeln baubar (Docker, eigenes Repo, Partner-Build). Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
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.
+1 -1
View File
@@ -41,9 +41,9 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantregistryclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
)
const workerPollInterval = 2 * time.Second
+4
View File
@@ -13,3 +13,7 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
-72
View File
@@ -1,72 +0,0 @@
// Package auditclient ist ein schlanker HTTP-Client fuer Core AUD-08
// (internal/audit, POST /audit/ingest). AI ist ein physisch getrenntes
// Go-Modul und kann Cores internal/audit daher nicht direkt importieren -
// nur ueber dessen HTTP-Schnittstelle. Gleiches Muster wie
// connect/internal/auditclient (ENG-06) und archive/internal/auditclient.
package auditclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient"
)
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. Absichtlich keine
// Update-/Delete-Methode: der Ingest-Endpunkt (AUD-08) ruft denselben
// alleinigen, anfuegenden Schreibpfad wie core-interne Aufrufer auf
// (audit.Log.Record), gegen den Core selbst per Datenbank-Trigger jede
// nachtraegliche Aenderung/Loeschung verweigert (AUD-02).
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()
}
@@ -1,48 +0,0 @@
package auditclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestIngest_SendsExpectedRequest(t *testing.T) {
var gotToken string
var gotBody ingestRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Service-Token")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
c := New(srv.URL, "test-token")
err := c.Ingest(context.Background(), "tenant_a", "svc:ai", "ai.decision.classify", "doc:1",
map[string]any{"model": "llama3", "prompt_version": 2})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if gotToken != "test-token" {
t.Errorf("token = %q, want test-token", gotToken)
}
if gotBody.Action != "ai.decision.classify" || gotBody.Target != "doc:1" {
t.Errorf("unerwarteter request: %+v", gotBody)
}
if gotBody.Metadata["model"] != "llama3" {
t.Errorf("metadata.model = %v, want llama3", gotBody.Metadata["model"])
}
}
func TestIngest_UnexpectedStatusIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "zugriff verweigert", http.StatusForbidden)
}))
defer srv.Close()
c := New(srv.URL, "test-token")
if err := c.Ingest(context.Background(), "tenant_a", "svc:ai", "ai.decision.classify", "doc:1", nil); err == nil {
t.Fatal("erwartet fehler bei status 403, habe nil")
}
}
+1 -1
View File
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
var testSchema = Schema{Categories: []string{"rechnung", "vertrag", "kuendigung"}}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
// Decision ist eine einzelne, abgeschlossene KI-gestuetzte Entscheidung
@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
type recordedRequest struct {
@@ -7,10 +7,10 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/loadtest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/common/loadtest"
)
// QA-08 Pruefung 1 (AK1): echte parallele Chat-Anfragen gegen den realen,
+1 -1
View File
@@ -12,8 +12,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
@@ -15,7 +15,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
-25
View File
@@ -1,25 +0,0 @@
// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe
// (Kopie des Musters aus connect/internal/httpclient, ENG-06): Zeitlimit
// statt endlosem Warten, keine Weiterleitungen (ein Service-Token wuerde
// sonst an das Weiterleitungsziel mitgesendet).
package httpclient
import (
"net/http"
"time"
)
const DefaultTimeout = 15 * time.Second
var shared = New(DefaultTimeout)
func Default() *http.Client { return shared }
func New(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
-29
View File
@@ -1,29 +0,0 @@
package httpclient
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNew_DoesNotFollowRedirects(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("weiterleitungsziel haette nie erreicht werden duerfen")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusFound)
}))
defer redirector.Close()
resp, err := Default().Get(redirector.URL)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status = %d, want %d (keine weiterleitung gefolgt)", resp.StatusCode, http.StatusFound)
}
}
-138
View File
@@ -1,138 +0,0 @@
// Package loadtest implementiert die fuer QA-07/QA-08 gemeinsam genutzte
// Last- und Leistungsmessung: parallele Sessions ausfuehren, Latenzen
// aufzeichnen, Durchsatz bestimmen. k6/vegeta sind auf diesem Rechner nicht
// installierbar (Projektregel: keine zusaetzlichen Toolchains/Dienste
// installieren) — echter Ersatz ohne externe Abhaengigkeit: reale
// nebenlaeufige Aufrufe ueber die Go-Standardbibliothek, gegen den echten,
// in dieser Sitzung gebauten Code. Eigenstaendige Kopie aus
// mail/internal/loadtest, da Go-Module keine internal/-Pakete teilen.
package loadtest
import (
"context"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Result fasst Latenz- und Durchsatzwerte eines Lasttest-Laufs zusammen.
type Result struct {
Protocol string
Concurrency int
Sessions int
Errors int
Duration time.Duration
Latencies []time.Duration // aufsteigend sortiert
}
// Throughput liefert Sessions pro Sekunde über die Gesamtlaufzeit.
func (r Result) Throughput() float64 {
if r.Duration <= 0 {
return 0
}
return float64(r.Sessions) / r.Duration.Seconds()
}
// Percentile liefert die Latenz am p-ten Perzentil (0.0–1.0).
func (r Result) Percentile(p float64) time.Duration {
if len(r.Latencies) == 0 {
return 0
}
idx := int(p * float64(len(r.Latencies)-1))
if idx < 0 {
idx = 0
}
if idx >= len(r.Latencies) {
idx = len(r.Latencies) - 1
}
return r.Latencies[idx]
}
// Max liefert die höchste gemessene Latenz.
func (r Result) Max() time.Duration {
if len(r.Latencies) == 0 {
return 0
}
return r.Latencies[len(r.Latencies)-1]
}
// Run führt totalSessions Sessions über maximal concurrency parallele
// Worker aus und misst je Session die Latenz von sessionFunc.
func Run(ctx context.Context, protocol string, concurrency, totalSessions int, sessionFunc func(ctx context.Context) error) Result {
var mu sync.Mutex
latencies := make([]time.Duration, 0, totalSessions)
errCount := 0
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < totalSessions; i++ {
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
t0 := time.Now()
err := sessionFunc(ctx)
elapsed := time.Since(t0)
mu.Lock()
latencies = append(latencies, elapsed)
if err != nil {
errCount++
}
mu.Unlock()
}()
}
wg.Wait()
duration := time.Since(start)
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
return Result{
Protocol: protocol,
Concurrency: concurrency,
Sessions: totalSessions,
Errors: errCount,
Duration: duration,
Latencies: latencies,
}
}
// ResourceSample ist eine Momentaufnahme des Ressourcenverbrauchs DIESES
// Prozesses: Heap über die Go-Runtime, kumulierte CPU-Zeit über
// /proc/self/stat (Linux, kein externes Werkzeug nötig).
type ResourceSample struct {
HeapAllocBytes uint64
CPUTimeSeconds float64
}
func SampleResources() ResourceSample {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return ResourceSample{HeapAllocBytes: m.HeapAlloc, CPUTimeSeconds: readSelfCPUTimeSeconds()}
}
// readSelfCPUTimeSeconds liefert die kumulierte User+System-CPU-Zeit dieses
// Prozesses in Sekunden. Liefert 0, wenn /proc/self/stat nicht lesbar ist
// (z. B. auf Nicht-Linux-Systemen) — Aufrufer behandeln das als "keine
// CPU-Messung verfügbar", kein harter Fehler.
func readSelfCPUTimeSeconds() float64 {
data, err := os.ReadFile("/proc/self/stat")
if err != nil {
return 0
}
fields := strings.Fields(string(data))
if len(fields) < 15 {
return 0
}
utime, err1 := strconv.ParseFloat(fields[13], 64)
stime, err2 := strconv.ParseFloat(fields[14], 64)
if err1 != nil || err2 != nil {
return 0
}
const clockTicksPerSecond = 100 // Linux-Standard-USER_HZ
return (utime + stime) / clockTicksPerSecond
}
@@ -15,7 +15,7 @@ import (
"net/url"
"sort"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
type Client struct {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
+1
View File
@@ -18,6 +18,7 @@ import (
// TESTSTRATEGIE-AI.md Abschnitt 3.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)ai/internal/svcauth/`),
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)ai/internal/tenantfeature/`),
regexp.MustCompile(`(^|/)ai/internal/semanticsearch/`),
regexp.MustCompile(`(^|/)ai/internal/semanticsearchapi/`),
+1 -1
View File
@@ -16,7 +16,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
@@ -25,6 +24,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
// realPool liefert einen frischen, migrierten Pool auf der Wegwerf-DB aus
+1 -1
View File
@@ -8,8 +8,8 @@ import (
"time"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/limits"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/loadtest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/common/loadtest"
)
// QA-08 Pruefung 2 (AK2): realistische parallele Klassifizierungs-/
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
@@ -13,7 +13,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
@@ -10,7 +10,7 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
+1 -1
View File
@@ -10,8 +10,8 @@ import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
type Server struct {
-20
View File
@@ -1,20 +0,0 @@
// Package svcauth vergleicht Service-Token in konstanter Zeit (eigene Kopie
// je Modul, siehe workflow/internal/svcauth — Go-Module teilen keine
// internal/-Pakete über Modulgrenzen; Lehre aus RET-14/F-08: Schutz beim
// ersten Entwurf einbauen statt nachzuruesten).
package svcauth
import (
"crypto/sha256"
"crypto/subtle"
)
// Equal vergleicht die SHA-256-Werte beider Eingaben in konstanter Zeit.
// 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
}
@@ -1,47 +0,0 @@
// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core
// TEN-01 (Tenant-Registry); AI ist ein eigenes Go-Modul und kann Cores
// Pakete nicht importieren. Gleiches Muster wie archive/internal/
// tenantregistryclient und dms/internal/tenantregistryclient (RET-12).
package tenantregistryclient
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: &http.Client{Timeout: 10 * time.Second}}
}
// VerifyTenant fragt Core, ob tenantSlug als Tenant gefuehrt wird. Transport-
// und Serverfehler kommen als Fehler zurueck, nie als "gueltig": der Aufrufer
// muss fail-closed 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)
}
}
@@ -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)")
}
}
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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"
)
func main() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+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
-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
@@ -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,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()
}
+1 -1
View File
@@ -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 {
@@ -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 {
@@ -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"
)
@@ -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"
-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)
}
}
-116
View File
@@ -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)
})
}
-105
View File
@@ -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))
}
}
+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"
)
@@ -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 (
+1 -1
View File
@@ -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 (
@@ -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"
)
-18
View File
@@ -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
}
-23
View File
@@ -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)
}
}
}
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
)
+1 -1
View File
@@ -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"
-1
View File
@@ -1 +0,0 @@
2
-26
View File
@@ -1,26 +0,0 @@
# common
Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar).
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
## Einbinden
```
go env -w GOPRIVATE=gitea.perlbach24.de
go get gitea.perlbach24.de/scripte/nexarch/common@latest
```
Build-Orte brauchen Lesezugriff auf Gitea: Read-only-Token in `~/.netrc` (`machine gitea.perlbach24.de login <user> password <token>`).
## Versionen
Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in `main`, der `common/` ändert. Module aktualisieren einzeln (`go get ...common@v0.X.Y`). Lokal mit mehreren Modulen gleichzeitig arbeiten: nicht eingechecktes `go.work`.
## Regeln
- Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal.
- Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden.
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul; die aktuelle Minor steht in `common/MINOR`, der Tag-Workflow setzt bei einer neuen Minor `v0.<minor>.0`.
- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl.
-49
View File
@@ -1,49 +0,0 @@
// Command pflichttestgate ist das CI-Gate für common/.
// Aufruf: pflichttestgate < geänderte-Dateien (eine pro Zeile, via stdin)
// Exit-Code 1 mit Liste der Verstöße, wenn ein sicherheitskritisches Package ohne
// begleitende Testdatei geändert wurde.
package main
import (
"bufio"
"fmt"
"os"
"regexp"
"gitea.perlbach24.de/scripte/nexarch/common/pflichttestgate"
)
var sensitive = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
regexp.MustCompile(`(^|/)common/auditclient/`),
regexp.MustCompile(`(^|/)common/policyclient/`),
regexp.MustCompile(`(^|/)common/apierror/`),
regexp.MustCompile(`(^|/)common/httpharden/`),
}
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles, sensitive)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
os.Exit(1)
}
-12
View File
@@ -1,15 +1,3 @@
module gitea.perlbach24.de/scripte/nexarch/common
go 1.25.0
toolchain go1.25.13
require github.com/jackc/pgx/v5 v5.9.2
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
-26
View File
@@ -1,26 +0,0 @@
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=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+6 -5
View File
@@ -57,15 +57,16 @@ func TestClientDoesNotFollowRedirects(t *testing.T) {
}
}
// Architekturtest: im Produktivcode von common gibt es keine Aufrufe ueber den
// Standard-Client ohne Zeitlimit. Die Verbraucher-Module pruefen ihren eigenen
// Code selbst.
// 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)
}
scanNoDefaultClient(t, root, root)
for _, mod := range []string{"common", "dms", "mail", "archive"} {
scanNoDefaultClient(t, repoRoot, filepath.Join(repoRoot, mod))
}
}
func scanNoDefaultClient(t *testing.T, repoRoot, root string) {
-247
View File
@@ -1,247 +0,0 @@
// Package migrate implements versioned, revertible SQL migrations (no ORM).
// Jedes Modul benennt seine Fortschrittstabelle selbst (New), damit sich
// Module in derselben Datenbank nicht gegenseitig den Fortschritt
// ueberschreiben (FDN-13).
package migrate
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
// Migration ist eine einzelne versionierte Migrationsdatei.
type Migration struct {
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
UpSQL string
DownSQL string
}
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
func Load(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
continue
}
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
}
sort.Strings(versions)
migrations := make([]Migration, 0, len(versions))
for _, v := range versions {
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
}
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
if err != nil {
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
}
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
}
return migrations, nil
}
var tableNamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Migrator fuehrt Migrationen gegen eine modulspezifisch benannte Tabelle aus.
type Migrator struct {
table string
}
// New liefert einen Migrator fuer die Fortschrittstabelle table (z. B.
// "dms_schema_migrations"). Der Name geht unquotiert in SQL und muss daher
// ^[a-z_][a-z0-9_]{0,62}$ entsprechen.
func New(table string) (*Migrator, error) {
if !tableNamePattern.MatchString(table) {
return nil, fmt.Errorf("migrate: ungueltiger tabellenname %q", table)
}
return &Migrator{table: table}, nil
}
// MustNew wie New, panikt bei ungueltigem Namen (fuer Paketvariablen).
func MustNew(table string) *Migrator {
mg, err := New(table)
if err != nil {
panic(err)
}
return mg
}
// Table liefert den Namen der Fortschrittstabelle.
func (mg *Migrator) Table() string { return mg.table }
const legacyTrackingTable = "schema_migrations"
// ensureTrackingTable legt mg.table an. Beim erstmaligen Anlegen wird
// ein vorhandener Bestand aus der frueheren, unqualifizierten
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
// veraendert). Die alte Tabelle
// bleibt unangetastet.
func (mg *Migrator) ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
tx, err := pool.Begin(ctx)
if err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, mg.table); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
var existed bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, mg.table).Scan(&existed); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if existed {
return tx.Commit(ctx)
}
if _, err := tx.Exec(ctx, `
CREATE TABLE `+mg.table+` (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`); err != nil {
return fmt.Errorf("%s anlegen: %w", mg.table, err)
}
if len(migrations) > 0 {
var legacy bool
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, legacyTrackingTable).Scan(&legacy); err != nil {
return fmt.Errorf("%s pruefen: %w", legacyTrackingTable, err)
}
if legacy {
versions := make([]string, len(migrations))
for i, m := range migrations {
versions[i] = m.Version
}
var hasBase bool
if err := tx.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM `+legacyTrackingTable+` WHERE version = $1)`, versions[0],
).Scan(&hasBase); err != nil {
return fmt.Errorf("%s lesen: %w", legacyTrackingTable, err)
}
if hasBase {
if _, err := tx.Exec(ctx, `
INSERT INTO `+mg.table+` (version, applied_at)
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
`, versions); err != nil {
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
}
}
}
}
return tx.Commit(ctx)
}
func (mg *Migrator) appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
rows, err := pool.Query(ctx, `SELECT version FROM `+mg.table)
if err != nil {
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
}
defer rows.Close()
applied := map[string]bool{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
}
applied[v] = true
}
return applied, rows.Err()
}
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
// DB funktioniert (Pruefung 1).
func (mg *Migrator) Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return nil, err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return nil, err
}
for _, m := range migrations {
if already[m.Version] {
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
}
if _, err := tx.Exec(ctx, `INSERT INTO `+mg.table+` (version) VALUES ($1)`, m.Version); err != nil {
_ = tx.Rollback(ctx)
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
}
applied = append(applied, m.Version)
}
return applied, nil
}
// DownOne macht die zuletzt angewendete Migration rueckgaengig
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
// oder "" falls keine Migration angewendet war.
func (mg *Migrator) DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
if err := mg.ensureTrackingTable(ctx, pool, migrations); err != nil {
return "", err
}
already, err := mg.appliedVersions(ctx, pool)
if err != nil {
return "", err
}
var last *Migration
for i := len(migrations) - 1; i >= 0; i-- {
if already[migrations[i].Version] {
last = &migrations[i]
break
}
}
if last == nil {
return "", nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
}
if _, err := tx.Exec(ctx, `DELETE FROM `+mg.table+` WHERE version = $1`, last.Version); err != nil {
_ = tx.Rollback(ctx)
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, mg.table, err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
}
return last.Version, nil
}
-98
View File
@@ -1,98 +0,0 @@
package migrate
import (
"context"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
)
func TestNewValidatesTableName(t *testing.T) {
for _, ok := range []string{"dms_schema_migrations", "_x", "a1"} {
if _, err := New(ok); err != nil {
t.Errorf("New(%q): %v", ok, err)
}
}
for _, bad := range []string{"", "1abc", "Dms", "a-b", "a;drop table x", "a b", "schema.table"} {
if _, err := New(bad); err == nil {
t.Errorf("New(%q) sollte scheitern", bad)
}
}
}
func writeMigrations(t *testing.T, files map[string]string) []Migration {
t.Helper()
dir := t.TempDir()
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
migs, err := Load(dir)
if err != nil {
t.Fatal(err)
}
return migs
}
func TestUpDownAndSeparateTrackingTables(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_a (id int)",
"0001_a.down.sql": "DROP TABLE mig_a",
"0002_b.up.sql": "CREATE TABLE mig_b (id int)",
"0002_b.down.sql": "DROP TABLE mig_b",
})
one := MustNew("one_schema_migrations")
two := MustNew("two_schema_migrations")
applied, err := one.Up(ctx, pool, migs)
if err != nil || len(applied) != 2 {
t.Fatalf("Up: applied=%v err=%v", applied, err)
}
again, err := one.Up(ctx, pool, migs)
if err != nil || len(again) != 0 {
t.Fatalf("zweites Up soll nichts anwenden: applied=%v err=%v", again, err)
}
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM two_schema_migrations`).Scan(&n); err == nil {
t.Fatal("two_schema_migrations darf vor dem ersten Lauf von two nicht existieren")
}
if _, err := two.DownOne(ctx, pool, migs); err != nil {
t.Fatalf("two.DownOne: %v", err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM one_schema_migrations`).Scan(&n); err != nil || n != 2 {
t.Fatalf("Fortschritt von one muss unberuehrt sein: n=%d err=%v", n, err)
}
v, err := one.DownOne(ctx, pool, migs)
if err != nil || v != "0002_b" {
t.Fatalf("DownOne: v=%q err=%v", v, err)
}
}
func TestLegacyTrackingTableIsAdopted(t *testing.T) {
ctx := context.Background()
pool := testdb.Pool(t, testdb.AdminDSN(t))
migs := writeMigrations(t, map[string]string{
"0001_a.up.sql": "CREATE TABLE mig_leg (id int)",
"0001_a.down.sql": "DROP TABLE mig_leg",
})
for _, q := range []string{
`CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,
`INSERT INTO schema_migrations (version) VALUES ('0001_a')`,
`CREATE TABLE mig_leg (id int)`,
} {
if _, err := pool.Exec(ctx, q); err != nil {
t.Fatal(err)
}
}
applied, err := MustNew("leg_schema_migrations").Up(ctx, pool, migs)
if err != nil || len(applied) != 0 {
t.Fatalf("uebernommene Version darf nicht erneut laufen: applied=%v err=%v", applied, err)
}
}
-53
View File
@@ -1,53 +0,0 @@
// Package pflichttestgate erzwingt: jede geänderte Go-Datei in einem sicherheitskritischen
// Bereich muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein.
// Die Bereiche (Pfadmuster) liefert der Aufrufer, die Logik ist für alle Module dieselbe.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
type Violation struct {
Package string
ChangedFile string
}
func IsSensitive(file string, patterns []*regexp.Regexp) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range patterns {
if re.MatchString(file) {
return true
}
}
return false
}
func CheckDiff(changedFiles []string, patterns []*regexp.Regexp) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if IsSensitive(f, patterns) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
-54
View File
@@ -1,54 +0,0 @@
package pflichttestgate
import (
"regexp"
"testing"
)
var testPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)common/svcauth/`),
regexp.MustCompile(`(^|/)common/tenantregistryclient/`),
}
func TestCheckDiff_BlocksSensitiveChangeWithoutTest(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/loadtest/loadtest.go"}
violations := CheckDiff(changed, testPatterns)
if len(violations) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations)
}
if violations[0].Package != "common/svcauth" || violations[0].ChangedFile != "common/svcauth/svcauth.go" {
t.Errorf("unerwarteter Verstoß: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/svcauth/svcauth_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_TestInOtherPackageDoesNotCount(t *testing.T) {
changed := []string{"common/svcauth/svcauth.go", "common/tenantregistryclient/tenantregistryclient_test.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 1 {
t.Fatalf("erwartet 1 Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_IgnoresNonGoAndUnrelated(t *testing.T) {
changed := []string{"common/svcauth/README.md", "common/httpclient/httpclient.go"}
if v := CheckDiff(changed, testPatterns); len(v) != 0 {
t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v)
}
}
func TestCheckDiff_NoPatternsMeansNoSensitiveAreas(t *testing.T) {
if v := CheckDiff([]string{"common/svcauth/svcauth.go"}, nil); len(v) != 0 {
t.Fatalf("ohne Muster darf nichts als sensibel gelten, bekommen %+v", v)
}
}
-78
View File
@@ -1,78 +0,0 @@
// Package testdb liefert Integrationstests einen Pool auf ein eigenes,
// frisch angelegtes Schema (search_path = Schema, public). Tabellen, die ein
// Test per CREATE TABLE anlegt, entstehen damit nur in diesem Schema; Pakete,
// Laeufe und parallele Checkouts teilen sich keine Tabellen mehr.
package testdb
import (
"context"
"fmt"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
var seq atomic.Int64
// AdminDSN liefert TEST_ADMIN_DSN. Fehlt er, wird uebersprungen; mit
// REQUIRE_DB=1 (CI) zaehlt das Fehlen als Fehler.
func AdminDSN(t testing.TB) string {
t.Helper()
dsn := os.Getenv("TEST_ADMIN_DSN")
if dsn != "" {
return dsn
}
if os.Getenv("REQUIRE_DB") == "1" {
t.Fatal("testdb: TEST_ADMIN_DSN nicht gesetzt, aber REQUIRE_DB=1")
}
t.Skip("testdb: TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
return ""
}
// Pool legt ein Schema an und liefert einen darauf ausgerichteten Pool;
// beides wird per t.Cleanup wieder entfernt.
func Pool(t testing.TB, dsn string) *pgxpool.Pool {
t.Helper()
pool, _ := PoolWithSchema(t, dsn, nil)
return pool
}
// PoolWithSchema wie Pool, zusaetzlich mit dem Schemanamen und der
// Moeglichkeit, die Verbindungskonfiguration anzupassen.
func PoolWithSchema(t testing.TB, dsn string, adjust func(*pgxpool.Config)) (*pgxpool.Pool, string) {
t.Helper()
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("testdb: verbindung: %v", err)
}
schema := fmt.Sprintf("connecttest_%d_%d", time.Now().UnixNano(), seq.Add(1))
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
admin.Close()
t.Fatalf("testdb: schema anlegen: %v", err)
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
admin.Close()
t.Fatalf("testdb: dsn: %v", err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public"
if adjust != nil {
adjust(cfg)
}
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
admin.Close()
t.Fatalf("testdb: pool: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
admin.Close()
})
return pool, schema
}
-11
View File
@@ -1,11 +0,0 @@
package testdb
import "testing"
func TestAdminDSNGesetzt(t *testing.T) {
t.Setenv("TEST_ADMIN_DSN", "postgres://x")
t.Setenv("REQUIRE_DB", "1")
if got := AdminDSN(t); got != "postgres://x" {
t.Fatalf("dsn = %q", got)
}
}
+4
View File
@@ -13,3 +13,7 @@ require (
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
@@ -12,7 +12,7 @@ import (
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
type Client struct {
@@ -1,71 +0,0 @@
// Package auditclient ist ein schlanker HTTP-Client fuer Core AUD-08
// (internal/audit, POST /audit/ingest). Connect ist ein physisch
// getrenntes Go-Modul und kann Cores internal/audit daher nicht direkt
// importieren — nur ueber dessen HTTP-Schnittstelle. Gleiches Muster wie
// archive/internal/auditclient.
package auditclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
)
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 zurueckgegeben — der
// Aufrufer entscheidet, ob/wie er das nachbehandelt (siehe
// connect/internal/audittrail fuer den ausfalltoleranten 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()
}
@@ -1,57 +0,0 @@
package auditclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestIngest_SendsTenantSlugAndServiceToken(t *testing.T) {
var gotBody ingestRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Service-Token")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("body dekodieren: %v", err)
}
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", map[string]any{"count": 3})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if gotToken != "svc-token-123" {
t.Fatalf("Service-Token nicht gesendet, erhalten: %q", gotToken)
}
if gotBody.TenantSlug != "acme" {
t.Fatalf("TenantSlug fehlt/falsch im Audit-Ereignis: %q", gotBody.TenantSlug)
}
if gotBody.Action != "connector.sync" {
t.Fatalf("Action falsch: %q", gotBody.Action)
}
}
func TestIngest_UnexpectedStatus_ReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := New(srv.URL, "svc-token-123")
if err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", nil); err == nil {
t.Fatal("erwarteter Fehler bei Status 500 blieb aus")
}
}
func TestIngest_TransportError_ReturnsError(t *testing.T) {
c := New("http://127.0.0.1:1", "svc-token-123")
if err := c.Ingest(context.Background(), "acme", "user-1", "connector.sync", "erp:kunde:42", nil); err == nil {
t.Fatal("erwarteter Transport-Fehler blieb aus")
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
type Recorder struct {
@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
)
func setupOutboxTest(t *testing.T) *pgxpool.Pool {
@@ -26,8 +26,8 @@ import (
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
)
@@ -13,7 +13,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/auditclient"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/audittrail"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
connectcrypto "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto"
@@ -14,8 +14,8 @@ import (
"io"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"net/http"
"net/url"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
// tenantKEKResponse mirrors Core internal/kek.tenantKEKResponse (JSON
@@ -14,8 +14,8 @@ import (
"io"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/registry"
)
-25
View File
@@ -1,25 +0,0 @@
// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe
// (Muster aus archive/internal/httpclient, QA-15 F-11): Zeitlimit statt
// endlosem Warten, keine Weiterleitungen (ein Service-Token wuerde sonst an
// das Weiterleitungsziel mitgesendet).
package httpclient
import (
"net/http"
"time"
)
const DefaultTimeout = 15 * time.Second
var shared = New(DefaultTimeout)
func Default() *http.Client { return shared }
func New(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
@@ -1,54 +0,0 @@
package httpclient
import (
"net/http"
"net/http/httptest"
"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)
}
}
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more