Merge pull request 'mail: httpclient, loadtest, policyclient aus common v0.1.0' (#386) from refactor/mail-common into main
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Mail-Schnittstellen-Vertragstests / contract-tests (push) Canceled after 0s

This commit was merged in pull request #386.
This commit is contained in:
patrick committed 2026-10-28 10:41:26 +01:00
commit a443911e6a
21 files changed
+81 -350

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.25.13
require (
gitea.perlbach24.de/scripte/nexarch/common v0.1.0
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
+2
View File
@@ -1,3 +1,5 @@
gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY=
gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
@@ -0,0 +1,51 @@
package archtest
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber
// den Standard-Client ohne Zeitlimit.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for i, line := range strings.Split(string(data), "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") {
continue
}
if forbidden.MatchString(line) {
rel, _ := filepath.Rel(root, path)
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
"net/url"
)
-29
View File
@@ -1,29 +0,0 @@
// Package httpclient liefert den HTTP-Client fuer ausgehende Dienstaufrufe
// (QA-15 F-11): Zeitlimit statt endlosem Warten auf einen haengenden
// Nachbardienst, und keine Weiterleitungen (ein Service-Token wuerde sonst an
// das Weiterleitungsziel mitgesendet).
package httpclient
import (
"net/http"
"time"
)
// DefaultTimeout gilt fuer einen kompletten Aufruf (Verbindung bis Antwortende).
const DefaultTimeout = 15 * time.Second
var shared = New(DefaultTimeout)
// Default ist der gemeinsame Client mit DefaultTimeout.
func Default() *http.Client { return shared }
// New liefert einen Client mit dem angegebenen Zeitlimit, der Weiterleitungen
// nicht folgt (die 3xx-Antwort wird zurueckgegeben).
func New(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
@@ -1,99 +0,0 @@
package httpclient
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestDefaultHasTimeout(t *testing.T) {
if Default().Timeout != DefaultTimeout || DefaultTimeout <= 0 {
t.Fatalf("timeout = %s", Default().Timeout)
}
}
func TestClientGivesUpOnHangingServer(t *testing.T) {
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }))
defer srv.Close()
defer close(release)
start := time.Now()
resp, err := New(200 * time.Millisecond).Get(srv.URL)
if err == nil {
_ = resp.Body.Close()
t.Fatal("kein fehler bei haengendem server")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("aufruf dauerte %s", elapsed)
}
}
func TestClientDoesNotFollowRedirects(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("weiterleitungsziel wurde aufgerufen (Service-Token-Leck)")
}))
defer target.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirector.Close()
req, _ := http.NewRequest(http.MethodPost, redirector.URL, strings.NewReader("x"))
req.Header.Set("X-Service-Token", "geheim")
resp, err := Default().Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, erwartet 307", resp.StatusCode)
}
}
// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber
// den Standard-Client ohne Zeitlimit.
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
for i, line := range strings.Split(string(data), "\n") {
if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") {
continue
}
if forbidden.MatchString(line) {
rel, _ := filepath.Rel(root, path)
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/loadtest"
"gitea.perlbach24.de/scripte/nexarch/common/loadtest"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
)
-1
View File
@@ -46,4 +46,3 @@ func TestCheckInputLimits_WithinLimits(t *testing.T) {
t.Fatalf("unerwarteter fehler innerhalb der obergrenzen: %v", err)
}
}
@@ -208,4 +208,3 @@ func TestProcessOCR_SuccessInvokesResultHandler(t *testing.T) {
t.Fatalf("erwartete status %q, habe %q", StatusSucceeded, job.Status)
}
}
-141
View File
@@ -1,141 +0,0 @@
// Package loadtest implementiert die für QA-07 gemeinsam genutzte
// Last- und Leistungsmessung: parallele Sessions ausführen, Latenzen
// aufzeichnen, Durchsatz und Ressourcenverbrauch bestimmen. k6/vegeta
// sind auf diesem Rechner nicht installierbar (Projektregel: keine
// zusätzlichen Toolchains/Dienste installieren) — echter Ersatz ohne
// externe Abhängigkeit: reale nebenläufige TCP-Sessions über die
// Go-Standardbibliothek, gegen die echten, in dieser Sitzung gebauten
// Protokollserver.
package loadtest
import (
"context"
"os"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Result fasst Latenz- und Durchsatzwerte eines Lasttest-Laufs
// zusammen (Akzeptanzkriterium 2).
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 (Akzeptanzkriterium 1: realistische Anzahl paralleler
// Sessions) 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 (Akzeptanzkriterium/Pflichtprüfung 3): 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
}
+1 -1
View File
@@ -31,8 +31,8 @@ import (
"net/http"
"strconv"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/policyclient"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
)
+1 -1
View File
@@ -6,8 +6,8 @@ import (
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/policyclient"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
)
@@ -147,17 +147,17 @@ func TestBuild_RegularValuesRoundTrip(t *testing.T) {
// fälschlich blockiert werden.
func TestValidateHeaderValue_ControlCharacterFuzz(t *testing.T) {
rejectCases := map[string]string{
"CR allein": "wert\rende",
"LF allein": "wert\nende",
"CRLF": "wert\r\nende",
"CR allein": "wert\rende",
"LF allein": "wert\nende",
"CRLF": "wert\r\nende",
"CRLFCRLF (Header-Doppelung)": "wert\r\n\r\nende",
"NUL": "wert\x00ende",
"BEL": "wert\x07ende",
"ESC": "wert\x1bende",
"vertical tab": "wert\x0bende",
"form feed": "wert\x0cende",
"NUL": "wert\x00ende",
"BEL": "wert\x07ende",
"ESC": "wert\x1bende",
"vertical tab": "wert\x0bende",
"form feed": "wert\x0cende",
"DEL (0x7f, kein C0, aber Steuerzeichen-Grenzfall)": "wert\x7fende",
"alle C0-Steuerzeichen außer Tab kombiniert": "a\x01\x02\x03\x04\x05b",
"alle C0-Steuerzeichen außer Tab kombiniert": "a\x01\x02\x03\x04\x05b",
}
for name, v := range rejectCases {
t.Run("reject/"+name, func(t *testing.T) {
@@ -168,11 +168,11 @@ func TestValidateHeaderValue_ControlCharacterFuzz(t *testing.T) {
}
acceptCases := map[string]string{
"leer": "",
"leer": "",
"tab erlaubt (gefaltete header)": "wert\twert2",
"druckbares ascii": "Normaler Betreff 123",
"unicode/umlaute": "Bericht für Mandant äöü ÄÖÜ ß",
"emoji": "Bericht 📄",
"druckbares ascii": "Normaler Betreff 123",
"unicode/umlaute": "Bericht für Mandant äöü ÄÖÜ ß",
"emoji": "Bericht 📄",
}
for name, v := range acceptCases {
t.Run("accept/"+name, func(t *testing.T) {
+4 -4
View File
@@ -37,10 +37,10 @@ var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die max
// Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment
// == false) als auch Anhänge (Akzeptanzkriterium 1/2).
type Part struct {
ContentType string
Filename string
Size int64
Content []byte
ContentType string
Filename string
Size int64
Content []byte
IsAttachment bool
}
@@ -12,7 +12,7 @@ import (
"fmt"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
const moduleName = "mail"
@@ -1,52 +0,0 @@
// Package policyclient fragt Core (RBAC-06, POST /authorize) nach der
// Rechteentscheidung; Fehler und Ausfall fuehren immer zur Ablehnung.
package policyclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type Client struct {
BaseURL string
ServiceToken string
HTTP *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), ServiceToken: serviceToken, HTTP: &http.Client{Timeout: 5 * time.Second}}
}
func (c *Client) Allowed(ctx context.Context, tenant, role, permission string) (bool, error) {
body := map[string]string{"role": role, "permission": permission}
if tenant != "" {
body["tenant"] = tenant
}
payload, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(payload))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTP.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("policy: status %d", resp.StatusCode)
}
var out struct {
Allowed *bool `json:"allowed"`
}
if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 1<<16)).Decode(&out); err != nil || out.Allowed == nil {
return false, fmt.Errorf("policy: antwort ohne entscheidung")
}
return *out.Allowed, nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/loadtest"
"gitea.perlbach24.de/scripte/nexarch/common/loadtest"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/loadtest"
"gitea.perlbach24.de/scripte/nexarch/common/loadtest"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/testdb"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"context"
"encoding/json"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
"net/http"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"io"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/httpclient"
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
)
// Config steuert Ziel und Zugangsdaten des Workflow-Modul-Adapters