QA-01: Teststrategie Connect #218

Merged
patrick merged 4 commits from feature/qa-01-01-teststrategie-connect into main 2026-09-26 23:47:43 +02:00
15 changed files with 883 additions and 0 deletions

No files matched your search

+12
View File
@@ -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
+15
View File
@@ -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)
}
}
+129
View File
@@ -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.
+138
View File
@@ -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/<systemtyp>/` 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.
@@ -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")
}
}
+92
View File
@@ -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)
}
}
@@ -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")
}
}
+128
View File
@@ -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/<systemtyp>/. 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)
}
@@ -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")
}
}
@@ -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."
@@ -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."
+13
View File
@@ -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"
}
+3
View File
@@ -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
1 EXTF;700;21;Buchungsstapel;9;20260314;;;;;;20260101;20261231;4;;;
2 Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;Konto;Gegenkonto (ohne BU-Schluessel);Belegdatum;Buchungstext;Belegfeld 1
3 307,80;S;10023;8400;14032026;Wartungsvertrag Q1 Musterhandel Nordlicht GmbH;RE-2026-004821
+28
View File
@@ -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"
}
+12
View File
@@ -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"
}
]
}