diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 565c7a90..836c7d55 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,6 +29,18 @@ jobs: working-directory: dms run: bash scripts/check-security-test-coverage.sh + connect-security-gate: + name: Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Gate-Selbsttest (beweist, dass fehlende Tests abgelehnt werden) + working-directory: connect + run: bash scripts/check-security-test-coverage_selftest.sh + - name: Gate gegen aktuellen Stand ausfuehren + working-directory: connect + run: bash scripts/check-security-test-coverage.sh + dms-build-test: name: DMS-Modul (Lint/Build/Test/Coverage/Artefakt) runs-on: ubuntu-latest diff --git a/connect/cmd/gen-test-corpus/main.go b/connect/cmd/gen-test-corpus/main.go new file mode 100644 index 00000000..ffbfb80d --- /dev/null +++ b/connect/cmd/gen-test-corpus/main.go @@ -0,0 +1,15 @@ +// Command gen-test-corpus erzeugt den QA-01-Referenzkorpus unter +// testdata/corpus/. Aufruf aus connect/ heraus: go run ./cmd/gen-test-corpus +package main + +import ( + "log" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/testcorpus" +) + +func main() { + if err := testcorpus.Generate("testdata/corpus"); err != nil { + log.Fatalf("gen-test-corpus: %v", err) + } +} diff --git a/connect/docs/QA-01-PRUEFPROTOKOLL.md b/connect/docs/QA-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..5136cecc --- /dev/null +++ b/connect/docs/QA-01-PRUEFPROTOKOLL.md @@ -0,0 +1,129 @@ +# QA-01 – Prüfprotokoll: Teststrategie Connect + +Welle 1. Keine Vorbedingungen. + +## Umsetzung + +- `connect/docs/QA-01-TESTSTRATEGIE.md` – Testpyramide (Anteile + + Begruendung fuer diesen Stack), Testarten je Ausbaustufe, Freigabe- + kriterien je Prüfgate, verbindliche Security-Merge-Gate-Regel + (Akzeptanzkriterium 1, 3, 4). +- `connect/scripts/check-security-test-coverage.sh` + + `_selftest.sh` – Mechanismus 1:1 aus `dms/scripts/...` uebernommen (kein + neuer Mechanismus, kein Umbau), Paketliste fuer Connect neu ermittelt + (`grep -rl "TenantSlug\|TenantID" internal --include=*.go` + manuelle + Pruefung Krypto-/Autorisierungs-Naehe): 15 Pakete. +- `.gitea/workflows/ci.yml` – neuer Job `connect-security-gate` + (Selbsttest + Gate-Lauf), analog `dms-security-gate` (Akzeptanzkriterium + 4). +- `connect/internal/crypto/envelope_test.go`, + `connect/internal/auditclient/auditclient_test.go`, + `connect/internal/policyclient/policyclient_test.go` – bei Einfuehrung + des Gates real fehlende Tests fuer drei sicherheitsrelevante Pakete + (Verschluesselung, Audit-Meldung, Rechteentscheidung) nachgeliefert; + ohne diese drei Dateien haette das neu eingefuehrte Gate den aktuellen + Stand sofort abgelehnt (echter, in dieser Kachel behobener Befund). +- `connect/internal/testcorpus/` – Generator fuer den Referenzkorpus + (ERP, CRM, DATEV, Microsoft Graph), vollstaendig mit Go-Standard- + bibliothek, keine neue Abhaengigkeit in `go.mod`. +- `connect/cmd/gen-test-corpus/main.go` – CLI-Einstiegspunkt, + `go run ./cmd/gen-test-corpus` aus `connect/` heraus. +- `connect/testdata/corpus/` – erzeugte Beispieldateien, mitcommitted + (Akzeptanzkriterium 2). +- `connect/internal/testcorpus/testcorpus_test.go` – prueft, dass alle + vier Systemtypen im generierten Korpus vorkommen, dass jede Datei fuer + ihren Typ gueltig strukturiert ist (JSON-Pflichtfeld je Typ bzw. + EXTF-Kopfzeile fuer DATEV) und dass wiederholte Laeufe denselben Inhalt + liefern (Reproduzierbarkeit). + +## Warum kein automatisierter Test fuer die Strategie-Datei selbst + +`QA-01-TESTSTRATEGIE.md` ist ein Prosa-Dokument (Festlegung, kein +ausfuehrbarer Code) – ein automatisierter Test dafuer waere Formsache ohne +echten Pruefwert. Stattdessen technisch verankert: der Merge-Gate- +Mechanismus hat einen Beweistest (`check-security-test-coverage_selftest.sh`), +der Korpus-Generator hat einen echten Test (`testcorpus_test.go`), und +jedes der drei nachgelieferten Sicherheitspakete hat jetzt eigene, +inhaltliche Tests (nicht nur Formsache: Rundtrip, Ablehnung bei falschem +Tenant, Ablehnung bei manipuliertem Ciphertext, Fail-Closed bei +Transport-/Statusfehlern). Damit ist jedes ausfuehrbare Artefakt dieser +Kachel getestet, das Strategiedokument bewusst nicht. + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Teststrategie-Dokument liegt vollstaendig vor und wurde geprueft | Dokument deckt Testpyramide, Testarten je Ausbaustufe und Freigabekriterien ab, anwendbar fuer alle Connect-Epics | **bestanden** – `connect/docs/QA-01-TESTSTRATEGIE.md` enthaelt alle drei Abschnitte, verweist auf ERP/CRM/DATEV/M365 als Epics gleichermassen, keine epicspezifische Engfuehrung | +| 2 | Testkorpus im Test auf Vollstaendigkeit je Systemtyp geprueft | Alle vier geforderten Systemtypen (ERP, CRM, DATEV, Microsoft Graph) vorhanden und je Typ strukturell gueltig | **bestanden** – `TestGenerate_AllSystemTypesCoveredAndParseable` (siehe Testlauf unten) prueft automatisiert Vorhandensein + Pflichtfeld je Typ; zusaetzlich manuell mit `file` bestaetigt (siehe Testlauf) | +| 3 | Freigabekriterien im Test auf Eindeutigkeit geprueft | Jedes Freigabekriterium ist als eindeutige, ueberpruefbare Bedingung formuliert (kein "sollte", kein Ermessensspielraum) | **bestanden** – manuelle Durchsicht der vier Freigabekriterien in `QA-01-TESTSTRATEGIE.md`: jedes benennt eine konkrete, technisch pruefbare Bedingung (abgelegtes Protokoll vorhanden ja/nein, realer Korpus-/Testsystembezug vorhanden ja/nein, Gate-Skript-Exitcode, Fehlerinjektionsnachweis vorhanden ja/nein) statt vager Formulierung. Zusaetzlich technisch bestaetigt fuer Freigabekriterium 3 (Merge-Gate) durch den Beweistest des Gates selbst (siehe Testlauf) | + +## Build/Test-Ergebnis (192.168.1.204, Wurf-Verzeichnis +`/root/nexarch-forks/qa-01-connect/connect`, per rsync aus lokalem Klon +`feature/qa-01-01-teststrategie-connect` synchronisiert) + +``` +go build ./... -> clean +go vet ./... -> clean +go test ./... -count=1 -p 1 -> alle Pakete PASS + (u.a. internal/crypto, + internal/auditclient, + internal/policyclient, + internal/testcorpus neu) +bash scripts/check-security-test-coverage_selftest.sh -> "Beweistest bestanden" (Exit 0); + Fall 2 (Tests aus internal/crypto + entfernt) lehnt korrekt ab +bash scripts/check-security-test-coverage.sh -> "Merge-Gate bestanden" (Exit 0), + 15 Pakete geprueft +go run ./cmd/gen-test-corpus -> 4 Dateien erzeugt (erp, crm, datev, m365) +file testdata/corpus/*/* -> crm/erp/m365: "JSON text data", + datev: "ASCII text, with CRLF + line terminators" +``` + +## DSGVO-Bestaetigung (fuer den Referenzkorpus) + +Alle vier Korpus-Dateien sind vollstaendig synthetisch, in dieser Sitzung +frei erfunden: fiktive Firma "Musterhandel Nordlicht GmbH", fiktive Person +"Anna Testperson", fiktive E-Mail-Adresse unter der reservierten +Beispieldomain `*.example` (RFC 2606), fiktive Debitoren-/Belegnummern, +fiktive USt-ID (`DE999999999`, kein reales Format-Praefix einer +existierenden Nummer), fiktive Betraege. Kein Inhalt wurde aus einer +echten Kunden-/Drittsystem-Antwort, einem Export oder Altsystem +uebernommen oder abgeleitet – uebernommen wurde ausschliesslich die +*Struktur/Feldbenennung* der jeweiligen real dokumentierten Schnittstelle +(HubSpot CRM v3, Microsoft Graph v1.0 `/users`, DATEV-EXTF-Format gemaess +bereits in `internal/datevexport` implementierter Spezifikation, generische +ERP-Belegstruktur analog `erpconnector`), nicht deren Inhalte. Das erfuellt +die Ticket-Vorgabe "reale Drittsystem-Antworten" im Sinne von *real +verwendete Schnittstellenschemata*, ohne gegen das Repo-Verbot echter +Kunden-/Mandantendaten zu verstossen (`CLAUDE.md`: keine echten Kunden-/ +Mandantendaten in Testfixtures). + +## Entscheidungen/Ermessen in dieser Sitzung + +- **Merge-Gate-Paketliste**: 15 Pakete real per Tenant-Bezug/Krypto-/ + Autorisierungs-Naehe ermittelt (nicht geschaetzt) – siehe Kommandos + oben in der Teststrategie. Drei davon (`crypto`, `auditclient`, + `policyclient`) hatten noch keine Tests; da ein neu eingefuehrtes, + aber sofort rot laufendes Gate keinen echten Nutzen haette (und dem + Grundsatz "keine Freigabe auf Zuruf" widerspraeche, wenn man es einfach + mit einer kleineren Liste "gruen faerbt"), wurden fuer diese drei + Pakete kleinstmoegliche, inhaltlich echte Tests ergaenzt statt sie aus + der Liste auszunehmen. +- **"Reale Drittsystem-Antworten" ohne Produktivzugang**: da diese Sitzung + keinen Zugang zu echten Kunden-ERP/CRM/DATEV/Microsoft-365-Systemen hat + (und DSGVO das ohnehin ausschliesst), wurde "real" als "Struktur/Schema + einer tatsaechlich eingesetzten, oeffentlich dokumentierten Schnittstelle" + ausgelegt statt als "Antwort eines echten Kundensystems". Diese Auslegung + ist im Abschnitt "DSGVO-Bestaetigung" oben begruendet; bei Bedarf vom + Fachbereich gegenzupruefen. + +## Gesamtergebnis + +**Bestanden.** Alle vier Akzeptanzkriterien erfuellt: Teststrategie +dokumentiert und epicuebergreifend anwendbar, Testkorpus mit allen vier +geforderten Systemtypen vorhanden und automatisiert geprueft, Freigabe- +kriterien eindeutig formuliert, Merge-Gate fuer sicherheitsrelevanten Code +technisch verankert (inkl. Beweistest) und die dabei real gefundene Luecke +(drei ungetestete Sicherheitspakete) in derselben Kachel geschlossen. Alle +drei Prüfungen vor der Abnahme real durchgefuehrt und bestanden. diff --git a/connect/docs/QA-01-TESTSTRATEGIE.md b/connect/docs/QA-01-TESTSTRATEGIE.md new file mode 100644 index 00000000..cdb2041e --- /dev/null +++ b/connect/docs/QA-01-TESTSTRATEGIE.md @@ -0,0 +1,138 @@ +# QA-01 – Teststrategie Connect + +Welle 1. Keine Vorbedingungen. Gilt fuer alle Connect-Epics (ERP/CRM- +Anbindung, DATEV, Microsoft 365, DMS-Interoperabilitaet, externe +Backup-Ziele) und liefert den Rahmen, auf dem `QA-02` (Connector-Engine) +und `QA-03` (ERP/DATEV/M365) ihre eigenen, fachspezifischen Prüfgates +aufbauen. + +## Testpyramide + +Stack: Go-Backend (`connect/`, eigenes Modul, Pakete unter `internal/` pro +fachlichem Bereich), baut auf Core-Infrastruktur (REST-API, Rate-Limiting, +Webhook-Zustellung, Token-Verwaltung, Audit-Log) statt eigener +Infrastruktur. + +| Ebene | Anteil (Zielbild) | Was hier getestet wird | Werkzeug | +|---|---|---|---| +| Unit | ca. 65% | Einzelne Funktionen/Methoden innerhalb eines `internal/`-Pakets: Feldabbildung (`fieldmapping`), Verschluesselung (`crypto`), DATEV-Formatbildung (`datevexport`), Zustandsuebergaenge in `syncjob`. Keine echte Drittsystem-Verbindung, kein echtes Netz noetig, wo Interfaces das zulassen. | `go test` | +| Integration | ca. 25% | Zusammenspiel mehrerer Pakete inkl. echter, pro Lauf frisch aufgesetzter Testdatenbank sowie HTTP-Handler-Tests (`*api`-Pakete, z. B. `statusapi`, `datevapi`) mit echter Anfrage/Antwort ueber `httptest`. Drittsystem-Antworten werden dabei ueber den Referenzkorpus (s. u.) simuliert, nicht frei erfunden. | `go test` gegen Testdatenbank, `net/http/httptest` | +| E2E | ca. 10% | Vollstaendiger Durchlauf eines Sync-Jobs gegen ein echtes, wenn auch als Testsystem markiertes Drittsystem (mind. ein realer Testlauf je Ausbaustufe, siehe Freigabekriterien). Bewusst hoeher als bei reinen Datenmodul-Tests (DMS), weil Connect fast ausschliesslich von der Korrektheit externer Schnittstellenvertraege lebt – ein Connector kann alle Unit-/Integrationstests bestehen und trotzdem am realen Drittsystemverhalten (Rate-Limits, Feldabweichungen, Zeitzonen) scheitern. | Manuelle/skriptgesteuerte Testlaeufe je Prüfgate-Kachel (QA-02/QA-03) | + +Begruendung: Die Kernrisiken bei Connect liegen nicht in der eigenen +Geschaeftslogik (die ist vergleichsweise einfach – Mapping, Zustandsmaschine, +Verschluesselung), sondern im Vertrag mit Drittsystemen. Deshalb hoeherer +E2E-Anteil als typische Datenmodule, aber dennoch klein gehalten (teuer, +verlangt echte Testzugaenge), und durch den Referenzkorpus (Ausbaustufe 1) +teilweise durch guenstigere Integrationstests ersetzt. + +## Testarten je Ausbaustufe + +Jede Ausbaustufe (Connector-Anlage bis Produktivbetrieb) verlangt eine +zunehmend strengere Prüftiefe: + +| Ausbaustufe | Pflicht-Testart | Freigabekriterium | +|---|---|---| +| 1. Skelett/Schema (Connector-Grundgeruest, Feldmapping) | Unit-Tests je Paket, Mapping-Validierung gegen den Referenzkorpus | Alle Unit-Tests gruen, Mapping deckt jedes Pflichtfeld des Korpus-Beispiels ab | +| 2. Integration gegen Korpus | Integrationstest: Connector verarbeitet die echte Beispielantwort aus `testdata/corpus//` fehlerfrei (Parsing, Feldabbildung, Persistenz) | Verarbeitung des Korpus-Beispiels ohne Fehler, Ergebnis stimmt mit erwarteten Zielwerten ueberein | +| 3. Fehlerinjektion | Simulierter Drittsystem-Ausfall (Verbindungsabbruch, Timeout, HTTP 5xx, unerwartete Antwortstruktur) gegen die Retry-/Fehlerbehandlungslogik | Retry-Verhalten greift nachweislich (kein stiller Datenverlust, kein Endlos-Retry, Fehler wird sichtbar protokolliert/audit-vermerkt) – **Freigabevoraussetzung fuer produktiven Einsatz der Retry-Logik**, siehe Produkt-DNA dieser Kachel | +| 4. Realer Testlauf | Mindestens ein Testlauf gegen ein echtes (Test-)Drittsystem je angebundenem Systemtyp, nicht nur gegen Mock-/Korpus-Antworten | Realer Testlauf dokumentiert (Zeitpunkt, Zielsystem, Ergebnis) in der jeweiligen Prüfgate-Kachel (QA-02/QA-03) – ohne diesen Nachweis keine Freigabe fuer den produktiven Rollout dieser Ausbaustufe | + +Ausbaustufen 3 und 4 sind bewusst nicht in dieser Kachel abschliessend +implementiert (siehe "Nicht Bestandteil"), sondern als verbindliche +Freigabekriterien fuer QA-02/QA-03 festgelegt. + +## Freigabekriterien je Prüfgate + +Damit "Freigabe auf Zuruf" technisch ausgeschlossen ist, gilt fuer jedes +Connect-Prüfgate (diese wie auch nachfolgende Kacheln) einheitlich: + +1. **Kein Prüfgate ohne abgelegtes Protokoll.** Jede Freigabe erfordert eine + `*-PRUEFPROTOKOLL.md` mit Zielwert und tatsaechlichem Ergebnis je + Prüfung – eine muendliche/Chat-Aussage "funktioniert" gilt nicht als + Freigabe. +2. **Kein Prüfgate nur gegen Mock-Antworten.** Mindestens ein Testlauf je + Ausbaustufe muss entweder den Referenzkorpus (reale Drittsystem- + Antwortstruktur, Ausbaustufe 1–2) oder ein echtes Testsystem + (Ausbaustufe 3–4) verwenden – reine Freihand-Mocks ohne Bezug zu einer + echten Schnittstelle reichen nicht. +3. **Kein Merge auf sicherheitsrelevanten Code ohne Test.** Siehe + Merge-Gate unten – technisch durchgesetzt, nicht nur dokumentiert. +4. **Fehlerinjektion vor Freigabe der Retry-Logik.** Ein Connector-Prüfgate + gilt fuer produktiven Einsatz erst als freigegeben, wenn mindestens ein + nachgewiesener Fehlerfall (Ausfall/Timeout) gegen die Retry-Logik + durchgefuehrt wurde (siehe Ausbaustufe 3). + +## Pflichttests für sicherheitsrelevanten Code (verbindliches Merge-Gate) + +Analog Core `QA-01`: Tenant-Scoping, Zugriffspruefung und Auth-nahe Logik +sind kein optionales Extra, sondern Merge-Voraussetzung. + +> Kein Merge von Code in einem als sicherheitsrelevant eingestuften Paket +> ohne mindestens eine begleitende Testdatei mit mindestens einem +> `func Test...`. + +Technisch durchgesetzt ueber `connect/scripts/check-security-test-coverage.sh` +(Mechanismus 1:1 aus `dms/scripts/check-security-test-coverage.sh` +uebernommen, kein neuer Mechanismus), als `connect-security-gate`-Job in +`.gitea/workflows/ci.yml` verankert. Paketliste (per +`grep -rl "TenantSlug\|TenantID" internal --include=*.go` sowie manueller +Pruefung auf Krypto-/Autorisierungs-Naehe ermittelt): + +``` +internal/crypto (Verschluesselung ruhender Zugangsdaten) +internal/credential (Zugangsdatenverwaltung) +internal/auditclient (Audit-Meldung an Core) +internal/audittrail (ausfalltoleranter Audit-Wrapper) +internal/policyclient (Rechteentscheidung gegen Core) +internal/archiveclient (Tenant-Bezug, externe Schnittstelle) +internal/datevexport (Tenant-Bezug) +internal/datevtransfer (Tenant-Bezug) +internal/datevapi (Tenant-Scoping per X-Tenant-Slug, Handler) +internal/dms2import (Tenant-Bezug) +internal/dms2importapi (Tenant-Bezug, Handler) +internal/fieldmapping (Tenant-Bezug) +internal/masterdatasync (Tenant-Bezug) +internal/syncjob (Tenant-Bezug) +internal/statusapi (Tenant-Scoping per X-Tenant-Slug, Handler) +``` + +Bei Einfuehrung: `internal/crypto`, `internal/auditclient` und +`internal/policyclient` hatten trotz Sicherheitsrelevanz noch keine +Testdatei (echter, in dieser Kachel behobener Befund – nicht nur +dokumentiert, siehe Prüfprotokoll). Kuenftige neue Drittsystem-Connector- +Pakete (z. B. Microsoft-Graph-Client) mit Tenant-Bezug sind bei ihrer +Einfuehrung in dieselbe Liste aufzunehmen. + +## Referenzkorpus + +Siehe `connect/testdata/corpus/{erp,crm,datev,m365}/`, erzeugt durch +`go run ./cmd/gen-test-corpus` aus `connect/internal/testcorpus`. Je +angebundenem Drittsystemtyp eine Beispielantwort, deren Struktur/Feldnamen +den real veroeffentlichten Schnittstellenschemata der jeweiligen +Systemklasse entsprechen (Details und DSGVO-Erklaerung siehe +`connect/docs/QA-01-PRUEFPROTOKOLL.md`): + +- **ERP**: generische Belegantwort (Kopf + Positionen), wie sie + `erpconnector` per REST liest/schreibt. +- **CRM**: HubSpot-CRM-v3-Contact-Schema (real dokumentiert: + `id` + `properties`-Map). +- **DATEV**: EXTF-Buchungsstapel-Zeile exakt im Format, das + `internal/datevexport` bereits produziert (Format-Kategorie 21). +- **Microsoft Graph**: v1.0-Antwortform fuer `GET /users` + (real dokumentiert: `@odata.context` + `value`-Array). + +Dieser Korpus deckt Ausbaustufe 1–2 ab (Schema-/Integrationspruefung gegen +reale Antwortformen). Ausbaustufe 3 (Fehlerinjektion) und 4 (realer +Testlauf gegen ein echtes Testsystem) sind gemaess "Nicht Bestandteil" +Aufgabe der jeweiligen Prüfgate-Kachel (QA-02/QA-03), die diesen Korpus als +Ausgangsbasis weiterverwenden. + +## Nicht Bestandteil dieser Kachel + +Konkrete Prüfgates fuer die Connector-Engine (`QA-02`) sowie fuer +ERP/DATEV/M365 (`QA-03`) sind eigene Kacheln. Diese Strategie liefert nur +den Rahmen (Pyramide, Testarten je Ausbaustufe, Freigabekriterien, Merge- +Gate) und den Referenzkorpus, den jene Kacheln als Eingabedaten sowie fuer +ihre eigene Fehlerinjektion (Ausbaustufe 3) und ihren realen Testlauf +(Ausbaustufe 4) wiederverwenden koennen. diff --git a/connect/internal/auditclient/auditclient_test.go b/connect/internal/auditclient/auditclient_test.go new file mode 100644 index 00000000..0d37be35 --- /dev/null +++ b/connect/internal/auditclient/auditclient_test.go @@ -0,0 +1,57 @@ +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") + } +} diff --git a/connect/internal/crypto/envelope_test.go b/connect/internal/crypto/envelope_test.go new file mode 100644 index 00000000..5b69ff2a --- /dev/null +++ b/connect/internal/crypto/envelope_test.go @@ -0,0 +1,92 @@ +package crypto + +import ( + "bytes" + "context" + "errors" + "testing" +) + +type fakeKEKProvider struct { + keys map[string][]byte + err error +} + +func (f *fakeKEKProvider) TenantKEK(_ context.Context, tenantSlug string) ([]byte, error) { + if f.err != nil { + return nil, f.err + } + kek, ok := f.keys[tenantSlug] + if !ok { + return nil, errors.New("fakeKEKProvider: unbekannter tenant") + } + return kek, nil +} + +func newFakeKEK(tenantSlug string) *fakeKEKProvider { + kek := make([]byte, KEKSize) + for i := range kek { + kek[i] = byte(i) ^ byte(len(tenantSlug)) + } + for i, c := range []byte(tenantSlug) { + if i < len(kek) { + kek[i] ^= c + } + } + return &fakeKEKProvider{keys: map[string][]byte{tenantSlug: kek}} +} + +func TestSealOpen_RoundTrip(t *testing.T) { + svc := NewService(newFakeKEK("acme")) + plaintext := []byte("geheimes-api-token") + + sealed, err := svc.Seal(context.Background(), "acme", plaintext) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if bytes.Contains(sealed.Ciphertext, plaintext) { + t.Fatal("Ciphertext enthaelt Klartext") + } + + opened, err := svc.Open(context.Background(), "acme", sealed) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(opened, plaintext) { + t.Fatalf("Open lieferte %q, erwartet %q", opened, plaintext) + } +} + +func TestOpen_WrongTenantKEK_Fails(t *testing.T) { + svc := NewService(newFakeKEK("acme")) + sealed, err := svc.Seal(context.Background(), "acme", []byte("secret")) + if err != nil { + t.Fatalf("Seal: %v", err) + } + + other := newFakeKEK("beta") + svcOther := NewService(other) + if _, err := svcOther.Open(context.Background(), "beta", sealed); !errors.Is(err, ErrDecryptFailed) { + t.Fatalf("erwartet ErrDecryptFailed bei falschem KEK, erhalten: %v", err) + } +} + +func TestSeal_KEKUnavailable(t *testing.T) { + svc := NewService(&fakeKEKProvider{err: errors.New("kek-dienst nicht erreichbar")}) + if _, err := svc.Seal(context.Background(), "acme", []byte("secret")); !errors.Is(err, ErrKEKUnavailable) { + t.Fatalf("erwartet ErrKEKUnavailable, erhalten: %v", err) + } +} + +func TestSeal_TamperedCiphertext_FailsOnOpen(t *testing.T) { + svc := NewService(newFakeKEK("acme")) + sealed, err := svc.Seal(context.Background(), "acme", []byte("secret")) + if err != nil { + t.Fatalf("Seal: %v", err) + } + sealed.Ciphertext[len(sealed.Ciphertext)-1] ^= 0xFF + + if _, err := svc.Open(context.Background(), "acme", sealed); !errors.Is(err, ErrDecryptFailed) { + t.Fatalf("erwartet ErrDecryptFailed bei manipuliertem Ciphertext, erhalten: %v", err) + } +} diff --git a/connect/internal/policyclient/policyclient_test.go b/connect/internal/policyclient/policyclient_test.go new file mode 100644 index 00000000..3ac23bed --- /dev/null +++ b/connect/internal/policyclient/policyclient_test.go @@ -0,0 +1,80 @@ +package policyclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAllowed_TenantScopedRequest_TrueOnAllow(t *testing.T) { + var gotBody map[string]string + 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) + } + json.NewEncoder(w).Encode(map[string]bool{"allowed": true}) + })) + defer srv.Close() + + c := New(srv.URL, "svc-token-123") + allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write") + if err != nil { + t.Fatalf("Allowed: %v", err) + } + if !allowed { + t.Fatal("erwartet allowed=true") + } + if gotToken != "svc-token-123" { + t.Fatalf("Service-Token nicht gesendet, erhalten: %q", gotToken) + } + if gotBody["tenant"] != "acme" { + t.Fatalf("Tenant fehlt/falsch in Anfrage: %q", gotBody["tenant"]) + } +} + +func TestAllowed_DeniedResponse_False(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]bool{"allowed": false}) + })) + defer srv.Close() + + c := New(srv.URL, "svc-token-123") + allowed, err := c.Allowed(context.Background(), "acme", "viewer", "connector.write") + if err != nil { + t.Fatalf("Allowed: %v", err) + } + if allowed { + t.Fatal("erwartet allowed=false") + } +} + +func TestAllowed_ServiceFailure_DeniesInsteadOfSilentAllow(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") + allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write") + if err == nil { + t.Fatal("erwarteter Fehler bei Status 500 blieb aus") + } + if allowed { + t.Fatal("Ausfall darf nie zu allowed=true fuehren") + } +} + +func TestAllowed_TransportFailure_DeniesInsteadOfSilentAllow(t *testing.T) { + c := New("http://127.0.0.1:1", "svc-token-123") + allowed, err := c.Allowed(context.Background(), "acme", "editor", "connector.write") + if err == nil { + t.Fatal("erwarteter Transport-Fehler blieb aus") + } + if allowed { + t.Fatal("Ausfall darf nie zu allowed=true fuehren") + } +} diff --git a/connect/internal/testcorpus/testcorpus.go b/connect/internal/testcorpus/testcorpus.go new file mode 100644 index 00000000..14b39c32 --- /dev/null +++ b/connect/internal/testcorpus/testcorpus.go @@ -0,0 +1,128 @@ +// Package testcorpus erzeugt den QA-01-Referenzkorpus fuer Connect: je +// angebundenem Drittsystemtyp (ERP, CRM, DATEV, Microsoft Graph) eine +// Beispielantwort, deren Feldnamen und Struktur den real veroeffentlichten +// Schnittstellenschemata der jeweiligen Systemklasse entsprechen, aber mit +// vollstaendig erfundenen (nicht aus einem echten Kunden-/Altsystem +// stammenden) Werten befuellt sind. Grundlage: +// - ERP: generische REST-Belegstruktur, wie sie erpconnector als +// Belegdatensatz liest/schreibt (internal/erpconnector). +// - CRM: HubSpot CRM API v3 Contact-Objektform +// (https://developers.hubspot.com/docs/api/crm/contacts) - "id" + +// "properties"-Map, real dokumentiertes, weit verbreitetes CRM-Schema. +// - DATEV: EXTF-Buchungsstapel-Zeile exakt im von datevexport erzeugten +// Format (Format-Kategorie 21, siehe internal/datevexport). +// - Microsoft Graph: v1.0-Antwortform fuer GET /users +// (https://learn.microsoft.com/en-us/graph/api/user-list) inkl. +// "@odata.context" und "value"-Array, real dokumentiertes Schema. +package testcorpus + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// Systems listet die vier in dieser Kachel geforderten Drittsystemtypen. +var Systems = []string{"erp", "crm", "datev", "m365"} + +// Generate schreibt fuer jeden Systemtyp genau eine Beispielantwort unter +// outDir//. Vorhandene Dateien werden ueberschrieben, damit der +// Korpus jederzeit reproduzierbar per Kommando neu erzeugt werden kann. +func Generate(outDir string) error { + writers := map[string]func(dir string) error{ + "erp": writeERP, + "crm": writeCRM, + "datev": writeDATEV, + "m365": writeGraph, + } + for _, sys := range Systems { + dir := filepath.Join(outDir, sys) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("testcorpus: verzeichnis %s: %w", dir, err) + } + if err := writers[sys](dir); err != nil { + return fmt.Errorf("testcorpus: %s: %w", sys, err) + } + } + return nil +} + +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +// writeERP erzeugt eine Beispiel-Belegantwort, wie sie erpconnector.Client +// von einem angebundenen ERP-System per generischem REST-Read erwartet +// (Belegkopf + Positionen, Feldnamen deutsch wie im Fachbereich ueblich). +func writeERP(dir string) error { + beleg := map[string]any{ + "belegnummer": "RE-2026-004821", + "belegdatum": "2026-03-14", + "kunde": map[string]any{ + "debitorennummer": "10023", + "name": "Musterhandel Nordlicht GmbH", + "ustid": "DE999999999", + }, + "positionen": []map[string]any{ + {"artikelnummer": "ART-1004", "bezeichnung": "Buerobedarf Sortiment A", "menge": 12, "einzelpreis": 4.90, "steuersatz": 19}, + {"artikelnummer": "ART-2071", "bezeichnung": "Wartungsvertrag Q1", "menge": 1, "einzelpreis": 249.00, "steuersatz": 19}, + }, + "gesamtbetrag_netto": 307.80, + "gesamtbetrag_brutto": 366.28, + "waehrung": "EUR", + } + return writeJSON(filepath.Join(dir, "beleg_response.json"), beleg) +} + +// writeCRM erzeugt eine Beispielantwort im HubSpot-CRM-v3-Contact-Schema +// (real dokumentiertes Feldschema: id + properties-Map). +func writeCRM(dir string) error { + contact := map[string]any{ + "id": "51023", + "properties": map[string]any{ + "firstname": "Anna", + "lastname": "Testperson", + "email": "anna.testperson@musterhandel-nordlicht.example", + "company": "Musterhandel Nordlicht GmbH", + "phone": "+49 431 5550100", + "lifecyclestage": "customer", + }, + "createdAt": "2025-11-02T09:14:00Z", + "updatedAt": "2026-03-10T16:41:22Z", + } + return writeJSON(filepath.Join(dir, "contact_response.json"), contact) +} + +// writeDATEV erzeugt eine EXTF-Buchungsstapel-Beispielzeile exakt im +// Format, das internal/datevexport produziert (Header + eine Datenzeile), +// als Referenz fuer Rundtrip-/Format-Pruefungen kuenftiger Prüfgates. +func writeDATEV(dir string) error { + content := "EXTF;700;21;Buchungsstapel;9;20260314;;;;;;20260101;20261231;4;;;\r\n" + + "Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;Konto;Gegenkonto (ohne BU-Schluessel);Belegdatum;Buchungstext;Belegfeld 1\r\n" + + "307,80;S;10023;8400;14032026;Wartungsvertrag Q1 Musterhandel Nordlicht GmbH;RE-2026-004821\r\n" + return os.WriteFile(filepath.Join(dir, "buchungsstapel.csv"), []byte(content), 0o644) +} + +// writeGraph erzeugt eine Beispielantwort im Microsoft-Graph-v1.0-Schema +// fuer GET /users (real dokumentiertes Schema: "@odata.context" + +// "value"-Array). +func writeGraph(dir string) error { + resp := map[string]any{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": []map[string]any{ + { + "id": "9f4b3c1a-0000-4c11-9a2e-000000000001", + "displayName": "Anna Testperson", + "mail": "anna.testperson@musterhandel-nordlicht.example", + "userPrincipalName": "anna.testperson@musterhandel-nordlicht.example", + "jobTitle": "Sachbearbeitung Buchhaltung", + }, + }, + } + return writeJSON(filepath.Join(dir, "users_response.json"), resp) +} diff --git a/connect/internal/testcorpus/testcorpus_test.go b/connect/internal/testcorpus/testcorpus_test.go new file mode 100644 index 00000000..4c7cb2d9 --- /dev/null +++ b/connect/internal/testcorpus/testcorpus_test.go @@ -0,0 +1,87 @@ +package testcorpus + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestGenerate_AllSystemTypesCoveredAndParseable prueft QA-01 Prüfung 2: +// der Testkorpus deckt alle vier geforderten Drittsystemtypen ab und jede +// erzeugte Datei ist fuer ihren Systemtyp gueltig strukturiert. +func TestGenerate_AllSystemTypesCoveredAndParseable(t *testing.T) { + dir := t.TempDir() + if err := Generate(dir); err != nil { + t.Fatalf("Generate: %v", err) + } + + for _, sys := range Systems { + entries, err := os.ReadDir(filepath.Join(dir, sys)) + if err != nil { + t.Fatalf("systemtyp %s: verzeichnis fehlt: %v", sys, err) + } + if len(entries) == 0 { + t.Fatalf("systemtyp %s: keine korpusdatei erzeugt", sys) + } + } + + assertJSONObjectField(t, filepath.Join(dir, "erp", "beleg_response.json"), "belegnummer") + assertJSONObjectField(t, filepath.Join(dir, "crm", "contact_response.json"), "properties") + assertJSONObjectField(t, filepath.Join(dir, "m365", "users_response.json"), "@odata.context") + assertDATEVFormat(t, filepath.Join(dir, "datev", "buchungsstapel.csv")) +} + +func assertJSONObjectField(t *testing.T, path, field string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: lesen: %v", path, err) + } + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + t.Fatalf("%s: kein gueltiges JSON: %v", path, err) + } + if _, ok := obj[field]; !ok { + t.Fatalf("%s: erwartetes Feld %q fehlt", path, field) + } +} + +func assertDATEVFormat(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: lesen: %v", path, err) + } + lines := strings.Split(string(data), "\r\n") + if len(lines) < 3 { + t.Fatalf("%s: erwartet Header-Zeile + Feldkopf + mind. eine Datenzeile, hat %d Zeilen", path, len(lines)) + } + if !strings.HasPrefix(lines[0], "EXTF;") { + t.Fatalf("%s: erste Zeile keine gueltige EXTF-Kopfzeile: %q", path, lines[0]) + } +} + +// TestGenerate_Idempotent prueft, dass der Korpus per Kommando jederzeit +// reproduzierbar neu erzeugt werden kann (AC2: reproduzierbarer Bestand). +func TestGenerate_Idempotent(t *testing.T) { + dir := t.TempDir() + if err := Generate(dir); err != nil { + t.Fatalf("erster Lauf: %v", err) + } + first, err := os.ReadFile(filepath.Join(dir, "erp", "beleg_response.json")) + if err != nil { + t.Fatalf("lesen nach erstem Lauf: %v", err) + } + if err := Generate(dir); err != nil { + t.Fatalf("zweiter Lauf: %v", err) + } + second, err := os.ReadFile(filepath.Join(dir, "erp", "beleg_response.json")) + if err != nil { + t.Fatalf("lesen nach zweitem Lauf: %v", err) + } + if string(first) != string(second) { + t.Fatal("wiederholte Generate()-Laeufe liefern unterschiedliche Inhalte") + } +} diff --git a/connect/scripts/check-security-test-coverage.sh b/connect/scripts/check-security-test-coverage.sh new file mode 100644 index 00000000..f0a850cc --- /dev/null +++ b/connect/scripts/check-security-test-coverage.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Merge-Gate QA-01 Akzeptanzkriterium 4 (analog dms/scripts/check-security- +# test-coverage.sh, FDN-07/dms-QA-01): sicherheitsrelevante Pakete (Tenant- +# Scoping, Verschluesselung, Auth-nahe Zugriffspruefung gegen Core) muessen +# mindestens eine Testdatei mit mindestens einer func Test... besitzen. +# Liste per grep -rl "TenantSlug\|TenantID" internal --include=*.go sowie +# manueller Pruefung auf Krypto-/Autorisierungs-Naehe ermittelt. +set -euo pipefail + +SECURITY_PACKAGES=( + "internal/crypto" + "internal/credential" + "internal/auditclient" + "internal/audittrail" + "internal/policyclient" + "internal/archiveclient" + "internal/datevexport" + "internal/datevtransfer" + "internal/datevapi" + "internal/dms2import" + "internal/dms2importapi" + "internal/fieldmapping" + "internal/masterdatasync" + "internal/syncjob" + "internal/statusapi" +) + +cd "$(dirname "$0")/.." + +fail=0 +for pkg in "${SECURITY_PACKAGES[@]}"; do + if [ ! -d "$pkg" ]; then + echo "FEHLER: sicherheitsrelevantes Paket fehlt: $pkg" >&2 + fail=1 + continue + fi + + test_files=$(find "$pkg" -maxdepth 1 -name '*_test.go') + if [ -z "$test_files" ]; then + echo "FEHLER: kein Test in sicherheitsrelevantem Paket: $pkg" >&2 + fail=1 + continue + fi + + has_test_func=0 + for f in $test_files; do + if grep -q '^func Test' "$f"; then + has_test_func=1 + fi + done + if [ "$has_test_func" -eq 0 ]; then + echo "FEHLER: Testdatei(en) in $pkg enthalten keine func Test...: $test_files" >&2 + fail=1 + fi +done + +if [ "$fail" -ne 0 ]; then + echo "Merge-Gate FEHLGESCHLAGEN: sicherheitsrelevanter Code ohne Pflichttest." >&2 + exit 1 +fi + +echo "Merge-Gate bestanden: alle sicherheitsrelevanten Pakete haben Tests." diff --git a/connect/scripts/check-security-test-coverage_selftest.sh b/connect/scripts/check-security-test-coverage_selftest.sh new file mode 100644 index 00000000..4590b55b --- /dev/null +++ b/connect/scripts/check-security-test-coverage_selftest.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Beweistest fuer das Merge-Gate selbst (analog dms/scripts/...): entfernt +# testweise alle Tests eines sicherheitsrelevanten Pakets in einer Kopie des +# Repos und bestaetigt, dass das Gate den Merge dann ablehnt (Exit-Code != 0) +# - sowie dass es im Normalfall durchlaesst (Exit-Code 0). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +cp -r "$REPO_ROOT" "$WORKDIR/connect" + +echo "== Fall 1: unveraendertes Repo -> Gate muss bestehen ==" +if ! bash "$WORKDIR/connect/scripts/check-security-test-coverage.sh"; then + echo "FEHLER: Gate schlug im Normalfall fehl (falsch-positiv)" >&2 + exit 1 +fi + +echo "== Fall 2: Tests von internal/crypto entfernt -> Gate muss ablehnen ==" +rm -f "$WORKDIR"/connect/internal/crypto/*_test.go +if bash "$WORKDIR/connect/scripts/check-security-test-coverage.sh"; then + echo "FEHLER: Gate liess Merge ohne Sicherheitstests durch (falsch-negativ)" >&2 + exit 1 +fi + +echo "Beweistest bestanden: Gate lehnt fehlende Sicherheitstests technisch ab." diff --git a/connect/testdata/corpus/crm/contact_response.json b/connect/testdata/corpus/crm/contact_response.json new file mode 100644 index 00000000..acbf9e19 --- /dev/null +++ b/connect/testdata/corpus/crm/contact_response.json @@ -0,0 +1,13 @@ +{ + "createdAt": "2025-11-02T09:14:00Z", + "id": "51023", + "properties": { + "company": "Musterhandel Nordlicht GmbH", + "email": "anna.testperson@musterhandel-nordlicht.example", + "firstname": "Anna", + "lastname": "Testperson", + "lifecyclestage": "customer", + "phone": "+49 431 5550100" + }, + "updatedAt": "2026-03-10T16:41:22Z" +} diff --git a/connect/testdata/corpus/datev/buchungsstapel.csv b/connect/testdata/corpus/datev/buchungsstapel.csv new file mode 100644 index 00000000..c9b4cd82 --- /dev/null +++ b/connect/testdata/corpus/datev/buchungsstapel.csv @@ -0,0 +1,3 @@ +EXTF;700;21;Buchungsstapel;9;20260314;;;;;;20260101;20261231;4;;; +Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;Konto;Gegenkonto (ohne BU-Schluessel);Belegdatum;Buchungstext;Belegfeld 1 +307,80;S;10023;8400;14032026;Wartungsvertrag Q1 Musterhandel Nordlicht GmbH;RE-2026-004821 diff --git a/connect/testdata/corpus/erp/beleg_response.json b/connect/testdata/corpus/erp/beleg_response.json new file mode 100644 index 00000000..d37d7423 --- /dev/null +++ b/connect/testdata/corpus/erp/beleg_response.json @@ -0,0 +1,28 @@ +{ + "belegdatum": "2026-03-14", + "belegnummer": "RE-2026-004821", + "gesamtbetrag_brutto": 366.28, + "gesamtbetrag_netto": 307.8, + "kunde": { + "debitorennummer": "10023", + "name": "Musterhandel Nordlicht GmbH", + "ustid": "DE999999999" + }, + "positionen": [ + { + "artikelnummer": "ART-1004", + "bezeichnung": "Buerobedarf Sortiment A", + "einzelpreis": 4.9, + "menge": 12, + "steuersatz": 19 + }, + { + "artikelnummer": "ART-2071", + "bezeichnung": "Wartungsvertrag Q1", + "einzelpreis": 249, + "menge": 1, + "steuersatz": 19 + } + ], + "waehrung": "EUR" +} diff --git a/connect/testdata/corpus/m365/users_response.json b/connect/testdata/corpus/m365/users_response.json new file mode 100644 index 00000000..aca4cf02 --- /dev/null +++ b/connect/testdata/corpus/m365/users_response.json @@ -0,0 +1,12 @@ +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users", + "value": [ + { + "displayName": "Anna Testperson", + "id": "9f4b3c1a-0000-4c11-9a2e-000000000001", + "jobTitle": "Sachbearbeitung Buchhaltung", + "mail": "anna.testperson@musterhandel-nordlicht.example", + "userPrincipalName": "anna.testperson@musterhandel-nordlicht.example" + } + ] +}