GOV-05: KI-Entscheidungs-Nachvollziehbarkeit (Kopplung Core AUD) #230

Merged
patrick merged 1 commits from feature/gov-05-ki-entscheidungs-nachvollziehbarkeit-kopplung-core-aud into main 2026-09-28 00:26:42 +02:00
9 changed files with 526 additions and 0 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# GOV-05 – Prüfprotokoll: KI-Entscheidungs-Nachvollziehbarkeit (Kopplung Core AUD)
Datum: 2026-09-27. Host: 204 (Wegwerf-Checkout `/root/nexarch-gov05`, keine echte DB nötig – reine Unit-/HTTP-Tests gegen Mock-Core).
## Umsetzung
- `ai/internal/httpclient` – gehärteter ausgehender `*http.Client` (15s Timeout, keine Weiterleitungen), Kopie des Musters aus `connect/internal/httpclient` (ENG-06).
- `ai/internal/auditclient` – schlanker Client gegen Cores `POST /audit/ingest` (AUD-08 → `internal/audit.Log.Record`), `X-Service-Token`-Auth. Bietet ausschließlich `Ingest` (anfügend) – keine Update-/Delete-Methode.
- `ai/internal/decisionaudit` – `Recorder.Record(ctx, Decision)` meldet eine KI-Entscheidung (Funktion, Modell, Prompt-Version, Eingabe-Referenz, Ergebnis) als ein Audit-Ereignis `ai.decision.<function>` mit `target = input_ref`. Schlägt die Meldung fehl, liefert `Record` einen Fehler statt die Entscheidung stillschweigend unprotokolliert zu lassen.
- `ai/internal/classify`: neue, zusätzliche Methode `Classifier.ClassifyWithSchemaAudited` (bestehende `ClassifyWithSchema` unverändert, keine bestehenden Aufrufer/Tests angefasst) klassifiziert wie bisher und meldet danach die Entscheidung über `decisionaudit.Recorder`. Demonstriert die Kopplung an einem konkreten KI-Entscheidungspfad (Modul-Adapter-Schnittstelle GOV-01 registriert die Objekttypen, an denen weitere Funktionen analog andocken können).
## Akzeptanzkriterien
1. **Jede KI-Entscheidung erzeugt einen Audit-Eintrag** – erfüllt: `ClassifyWithSchemaAudited` ruft nach jeder Klassifizierung `Recorder.Record`; schlägt die Meldung fehl, liefert die Methode einen Fehler (fail-closed) statt eine unprotokollierte Entscheidung als erfolgreich auszugeben.
2. **Eintrag verweist auf Modell- und Prompt-Version** – erfüllt: `Decision.Model` und `Decision.PromptVersion` werden 1:1 in die an Core gemeldeten Metadaten (`model`, `prompt_version`) übernommen, zusammen mit `input_ref` und `result`.
3. **Eintrag ist nachträglich unveränderlich** – erfüllt: `auditclient.Client` bietet ausschließlich `Ingest` (anfügend), keinen Update-/Delete-Pfad. Wiederholte Entscheidungen zum selben Objekt erzeugen zwingend je einen neuen, unveränderten Eintrag statt einen bestehenden zu überschreiben (Test). Die technische Durchsetzung selbst – Datenbank-Trigger gegen `UPDATE`/`DELETE` auf `audit_events` – liegt bei Core (AUD-02, Migration `0005_audit_append_only`) und ist dort bereits geprüft; ein physisch getrenntes Modul wie AI hat ohnehin keinen direkten DB-Zugriff auf `audit_events`, sondern ausschließlich den REST-Ingest-Pfad.
## Prüfungen vor der Abnahme
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Audit-Eintrag an Testentscheidung nachvollzogen | **Bestanden.** `TestRecord_CreatesAuditEntryForDecision` (decisionaudit) und `TestClassifyWithSchemaAudited_RecordsDecisionAtCore` (classify, End-to-End über den echten Entscheidungspfad inkl. Modellaufruf via Mock-Provider): genau ein Eintrag mit `action=ai.decision.classify` und korrektem `target=input_ref` beim Test-Core. |
| 2 | Verweis auf Modell-/Promptversion im Test bestätigt | **Bestanden.** `TestRecord_MetadataReferencesModelAndPromptVersion` und `TestClassifyWithSchemaAudited_RecordsDecisionAtCore`: Metadaten enthalten `model` und `prompt_version` exakt wie übergeben. |
| 3 | Unveränderlichkeit des Eintrags im Test geprüft | **Bestanden.** `TestRecord_RepeatedDecisionsCreateSeparateImmutableEntries`: zwei Entscheidungen zum selben `input_ref` mit unterschiedlichem Ergebnis/Prompt-Version erzeugen zwei getrennte, jeweils unverändert beim Core ankommende Einträge – kein Überschreiben. Zusätzlich `TestClassifyWithSchemaAudited_AuditFailureFailsClosed`: schlägt die Meldung fehl, gilt die Entscheidung nicht als protokolliert (Fehler statt stiller Erfolg). |
## Testlauf (real, Host 204)
```
go build ./... → keine Ausgabe (sauber)
go vet ./... → keine Ausgabe (sauber)
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient 0.008s
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/classify 0.009s
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit 0.008s
ok gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient 0.005s
```
Vollständiger `go test ./...` über das gesamte `ai`-Modul: alle Pakete `ok`, kein `FAIL`, kein `panic`. Keine Regressionen an bestehenden `classify`-Tests (unveränderte `ClassifyWithSchema`-API/Tests weiterhin grün).
## Nicht Bestandteil dieser Kachel
`QA-05` (Datenschutz- & Governance-Prüfung) – nicht umgesetzt, nur die Audit-Schnittstelle (`decisionaudit.Recorder`) als Grundlage vorbereitet. Die Kopplung wurde am Beispiel `classify` demonstriert; weitere Entscheidungsfunktionen (`extract`, `summarize`, `docchat`, …) können nach demselben Muster (`decisionaudit.Recorder` + optionale `...Audited`-Methode) andocken, ist aber kein AK dieser Kachel und daher nicht in jedem bestehenden Paket verdrahtet.
+72
View File
@@ -0,0 +1,72 @@
// 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()
}
@@ -0,0 +1,48 @@
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")
}
}
+37
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"strings"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
@@ -92,6 +93,42 @@ func (c *Classifier) ClassifyWithSchema(ctx context.Context, schema Schema, docu
return parseAndValidate(schema, resp.Message.Content), nil
}
// DecisionContext trägt die für GOV-05 nötigen Angaben, um eine
// Klassifizierung als KI-Entscheidung an Cores Audit-Log zu melden.
type DecisionContext struct {
Recorder *decisionaudit.Recorder
TenantSlug string
Actor string
PromptVersion int
}
// ClassifyWithSchemaAudited klassifiziert wie ClassifyWithSchema und meldet
// die Entscheidung zusätzlich an Cores Audit-Log (GOV-05 AK1/AK2), referenziert
// über inputRef (z.B. Dokument-ID). Schlägt die Meldung fehl, liefert diese
// Methode einen Fehler statt eine unprotokollierte Entscheidung gelten zu
// lassen.
func (c *Classifier) ClassifyWithSchemaAudited(ctx context.Context, schema Schema, documentText string, dc DecisionContext, inputRef string) (Result, error) {
result, err := c.ClassifyWithSchema(ctx, schema, documentText)
if err != nil {
return Result{}, err
}
if dc.Recorder == nil {
return result, nil
}
if err := dc.Recorder.Record(ctx, decisionaudit.Decision{
TenantSlug: dc.TenantSlug,
Actor: dc.Actor,
Function: "classify",
Model: c.Model,
PromptVersion: dc.PromptVersion,
InputRef: inputRef,
Result: map[string]any{"category": result.Category, "confidence": result.Confidence},
}); err != nil {
return Result{}, fmt.Errorf("classify: entscheidung protokollieren: %w", err)
}
return result, nil
}
func buildPrompt(schema Schema, documentText string) string {
var b strings.Builder
b.WriteString("Ordne das folgende Dokument genau einer dieser Kategorien zu: ")
+68
View File
@@ -2,9 +2,14 @@ package classify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"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"
@@ -31,6 +36,69 @@ func TestClassifyWithSchema_ValidResponse_ReturnsCategoryWithConfidence(t *testi
}
}
// GOV-05 AK1 + AK2 + Prüfung 1/2: eine echte Klassifizierung erzeugt einen
// Audit-Eintrag bei Core, der auf Modell- und Prompt-Version verweist.
func TestClassifyWithSchemaAudited_RecordsDecisionAtCore(t *testing.T) {
var gotAction, gotTarget string
var gotMetadata map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Action string `json:"action"`
Target string `json:"target"`
Metadata map[string]any `json:"metadata"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
gotAction, gotTarget, gotMetadata = req.Action, req.Target, req.Metadata
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"rechnung","confidence":0.92,"reasoning":"x"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
rec := decisionaudit.NewRecorder(auditclient.New(srv.URL, "tok"))
got, err := c.ClassifyWithSchemaAudited(context.Background(), testSchema, "Rechnung Nr. 123",
DecisionContext{Recorder: rec, TenantSlug: "tenant_a", Actor: "svc:ai", PromptVersion: 4}, "dms/doc:7")
if err != nil {
t.Fatalf("ClassifyWithSchemaAudited: %v", err)
}
if got.Category != "rechnung" {
t.Fatalf("Category = %q, want rechnung", got.Category)
}
if gotAction != "ai.decision.classify" || gotTarget != "dms/doc:7" {
t.Errorf("action/target = %q/%q, want ai.decision.classify/dms/doc:7", gotAction, gotTarget)
}
if gotMetadata["model"] != "test-model" {
t.Errorf("metadata.model = %v, want test-model", gotMetadata["model"])
}
if gotMetadata["prompt_version"] != float64(4) {
t.Errorf("metadata.prompt_version = %v, want 4", gotMetadata["prompt_version"])
}
}
// GOV-05: schlägt die Audit-Meldung fehl, gilt die Entscheidung als nicht
// protokolliert - kein stiller Verlust der Nachvollziehbarkeit.
func TestClassifyWithSchemaAudited_AuditFailureFailsClosed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "core nicht erreichbar", http.StatusServiceUnavailable)
}))
defer srv.Close()
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"rechnung","confidence":0.9,"reasoning":"x"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
rec := decisionaudit.NewRecorder(auditclient.New(srv.URL, "tok"))
_, err := c.ClassifyWithSchemaAudited(context.Background(), testSchema, "irrelevant",
DecisionContext{Recorder: rec, TenantSlug: "tenant_a", Actor: "svc:ai", PromptVersion: 1}, "dms/doc:8")
if err == nil {
t.Fatal("erwartet fehler, wenn audit-meldung fehlschlägt, habe nil")
}
}
// AK2 + Prüfung 3: Ausgabeschema wird auch bei Text rund um das JSON eingehalten.
func TestClassifyWithSchema_ExtraTextAroundJSON_StillParsesSchema(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
@@ -0,0 +1,67 @@
// Package decisionaudit implementiert GOV-05: die Protokollierung jeder
// KI-gestuetzten Entscheidung (Modell, Prompt-Version, Eingabe-Referenz,
// Ergebnis) ueber Cores zentrales Audit-Log (AUD-01, ueber AUD-08), statt
// ein eigenes Protokoll im AI-Modul zu fuehren.
package decisionaudit
import (
"context"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
)
// Decision ist eine einzelne, abgeschlossene KI-gestuetzte Entscheidung
// (AK1: Modell, Prompt-Version, Eingabe-Referenz, Ergebnis).
type Decision struct {
TenantSlug string
Actor string
Function string
Model string
PromptVersion int
InputRef string
Result map[string]any
}
func (d Decision) validate() error {
if d.Function == "" {
return fmt.Errorf("decisionaudit: function darf nicht leer sein")
}
if d.Model == "" {
return fmt.Errorf("decisionaudit: model darf nicht leer sein")
}
if d.InputRef == "" {
return fmt.Errorf("decisionaudit: input_ref darf nicht leer sein")
}
return nil
}
// Recorder meldet KI-Entscheidungen an Core.
type Recorder struct {
client *auditclient.Client
}
func NewRecorder(client *auditclient.Client) *Recorder {
return &Recorder{client: client}
}
// Record meldet EINE KI-Entscheidung an Cores Audit-Log (AK1). Der Eintrag
// verweist per Metadaten auf Modell- und Prompt-Version (AK2). Schlaegt die
// Meldung fehl, liefert Record einen Fehler statt die Entscheidung
// unprotokolliert gelten zu lassen - der Aufrufer entscheidet, ob die
// zugrundeliegende Fachaktion trotzdem Bestand hat.
func (r *Recorder) Record(ctx context.Context, d Decision) error {
if err := d.validate(); err != nil {
return err
}
metadata := map[string]any{
"model": d.Model,
"prompt_version": d.PromptVersion,
"input_ref": d.InputRef,
"result": d.Result,
}
if err := r.client.Ingest(ctx, d.TenantSlug, d.Actor, "ai.decision."+d.Function, d.InputRef, metadata); err != nil {
return fmt.Errorf("decisionaudit: entscheidung melden: %w", err)
}
return nil
}
@@ -0,0 +1,139 @@
package decisionaudit
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/auditclient"
)
type recordedRequest struct {
TenantSlug string `json:"tenant_slug"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Metadata map[string]any `json:"metadata"`
}
func mockCore(t *testing.T, received *[]recordedRequest) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req recordedRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("request dekodieren: %v", err)
}
*received = append(*received, req)
w.WriteHeader(http.StatusAccepted)
}))
}
// AK1 + Pruefung 1: jede KI-Entscheidung erzeugt einen Audit-Eintrag.
func TestRecord_CreatesAuditEntryForDecision(t *testing.T) {
var received []recordedRequest
srv := mockCore(t, &received)
defer srv.Close()
r := NewRecorder(auditclient.New(srv.URL, "tok"))
err := r.Record(context.Background(), Decision{
TenantSlug: "tenant_a",
Actor: "svc:ai",
Function: "classify",
Model: "llama3.1",
PromptVersion: 3,
InputRef: "dms/doc:42",
Result: map[string]any{"category": "rechnung", "confidence": 0.9},
})
if err != nil {
t.Fatalf("Record: %v", err)
}
if len(received) != 1 {
t.Fatalf("erwartet genau 1 audit-eintrag, hatte %d", len(received))
}
if received[0].Action != "ai.decision.classify" {
t.Errorf("action = %q, want ai.decision.classify", received[0].Action)
}
if received[0].Target != "dms/doc:42" {
t.Errorf("target = %q, want dms/doc:42", received[0].Target)
}
}
// AK2 + Pruefung 2: Eintrag verweist auf Modell- und Prompt-Version.
func TestRecord_MetadataReferencesModelAndPromptVersion(t *testing.T) {
var received []recordedRequest
srv := mockCore(t, &received)
defer srv.Close()
r := NewRecorder(auditclient.New(srv.URL, "tok"))
if err := r.Record(context.Background(), Decision{
TenantSlug: "tenant_a", Actor: "svc:ai", Function: "summarize",
Model: "llama3.1", PromptVersion: 7, InputRef: "mail/thread:9",
Result: map[string]any{"summary": "gekuerzt"},
}); err != nil {
t.Fatalf("Record: %v", err)
}
got := received[0].Metadata
if got["model"] != "llama3.1" {
t.Errorf("metadata.model = %v, want llama3.1", got["model"])
}
if got["prompt_version"] != float64(7) {
t.Errorf("metadata.prompt_version = %v, want 7", got["prompt_version"])
}
if got["input_ref"] != "mail/thread:9" {
t.Errorf("metadata.input_ref = %v, want mail/thread:9", got["input_ref"])
}
resultField, ok := got["result"].(map[string]any)
if !ok || resultField["summary"] != "gekuerzt" {
t.Errorf("metadata.result = %v, erwartet enthaltenes ergebnis", got["result"])
}
}
// AK3 + Pruefung 3: Unveraenderlichkeit. Der Client bietet ausschliesslich
// Ingest (anfuegend) - keine Update-/Delete-Operation existiert im
// Vertrag, wiederholte Entscheidungen zum selben Objekt erzeugen daher
// zwingend je einen NEUEN, unveraenderten Eintrag statt einen bestehenden zu
// ueberschreiben. Die technische Durchsetzung (DB-Trigger gegen
// UPDATE/DELETE auf audit_events) liegt bei Core (AUD-02) und ist dort
// bereits geprueft.
func TestRecord_RepeatedDecisionsCreateSeparateImmutableEntries(t *testing.T) {
var received []recordedRequest
srv := mockCore(t, &received)
defer srv.Close()
r := NewRecorder(auditclient.New(srv.URL, "tok"))
base := Decision{
TenantSlug: "tenant_a", Actor: "svc:ai", Function: "classify",
Model: "llama3.1", PromptVersion: 1, InputRef: "dms/doc:42",
}
first := base
first.Result = map[string]any{"category": "vertrag", "confidence": 0.4}
second := base
second.PromptVersion = 2
second.Result = map[string]any{"category": "rechnung", "confidence": 0.95}
if err := r.Record(context.Background(), first); err != nil {
t.Fatalf("Record (1): %v", err)
}
if err := r.Record(context.Background(), second); err != nil {
t.Fatalf("Record (2): %v", err)
}
if len(received) != 2 {
t.Fatalf("erwartet 2 getrennte eintraege statt ueberschreiben, hatte %d", len(received))
}
if received[0].Metadata["result"].(map[string]any)["category"] != "vertrag" {
t.Errorf("erster eintrag wurde veraendert: %+v", received[0])
}
if received[1].Metadata["result"].(map[string]any)["category"] != "rechnung" {
t.Errorf("zweiter eintrag fehlt/falsch: %+v", received[1])
}
}
func TestRecord_RejectsIncompleteDecision(t *testing.T) {
r := NewRecorder(auditclient.New("http://unused.invalid", "tok"))
if err := r.Record(context.Background(), Decision{TenantSlug: "t", Actor: "a"}); err == nil {
t.Fatal("erwartet fehler bei fehlender function/model/input_ref, habe nil")
}
}
+25
View File
@@ -0,0 +1,25 @@
// 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
@@ -0,0 +1,29 @@
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)
}
}