From 0c8b3fe2b3cca59274f4b11a049b08361cc93670 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 27 Oct 2026 15:25:26 +0100 Subject: [PATCH] feat(common): parametrisiertes Pflichttest-Gate, Gate fuer common/ selbst CheckDiff(changed, patterns): gleiche Logik wie die Modul-Gates, Pfadmuster vom Aufrufer. cmd/pflichttestgate + Workflow decken svcauth, tenantregistryclient, auditclient, policyclient, apierror, httpharden ab. README: Auswirkungsliste und Minor-Version bei Breaking Changes. Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/common-pflichttest-gate.yml | 23 +++++++++ common/README.md | 5 +- common/cmd/pflichttestgate/main.go | 49 ++++++++++++++++++ common/pflichttestgate/gate.go | 53 +++++++++++++++++++ common/pflichttestgate/gate_test.go | 54 ++++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/common-pflichttest-gate.yml create mode 100644 common/cmd/pflichttestgate/main.go create mode 100644 common/pflichttestgate/gate.go create mode 100644 common/pflichttestgate/gate_test.go diff --git a/.gitea/workflows/common-pflichttest-gate.yml b/.gitea/workflows/common-pflichttest-gate.yml new file mode 100644 index 00000000..fafdb53a --- /dev/null +++ b/.gitea/workflows/common-pflichttest-gate.yml @@ -0,0 +1,23 @@ +name: common Pflichttest-Gate + +on: + pull_request: + paths: + - "common/**" + +jobs: + pflichttest-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Gate bauen + working-directory: common + run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate + - name: Geänderte Dateien gegen Pflichttest-Regel pruefen + run: | + git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- common/ | /tmp/pflichttestgate diff --git a/common/README.md b/common/README.md index d0176fb5..6c3fd24f 100644 --- a/common/README.md +++ b/common/README.md @@ -2,7 +2,7 @@ Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar). -Pakete: `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. +Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`. ## Einbinden @@ -21,3 +21,6 @@ Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in - Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal. - Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden. + +- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul. +- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl. diff --git a/common/cmd/pflichttestgate/main.go b/common/cmd/pflichttestgate/main.go new file mode 100644 index 00000000..48e3e947 --- /dev/null +++ b/common/cmd/pflichttestgate/main.go @@ -0,0 +1,49 @@ +// Command pflichttestgate ist das CI-Gate für common/. +// Aufruf: pflichttestgate < geänderte-Dateien (eine pro Zeile, via stdin) +// Exit-Code 1 mit Liste der Verstöße, wenn ein sicherheitskritisches Package ohne +// begleitende Testdatei geändert wurde. +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + + "gitea.perlbach24.de/scripte/nexarch/common/pflichttestgate" +) + +var sensitive = []*regexp.Regexp{ + regexp.MustCompile(`(^|/)common/svcauth/`), + regexp.MustCompile(`(^|/)common/tenantregistryclient/`), + regexp.MustCompile(`(^|/)common/auditclient/`), + regexp.MustCompile(`(^|/)common/policyclient/`), + regexp.MustCompile(`(^|/)common/apierror/`), + regexp.MustCompile(`(^|/)common/httpharden/`), +} + +func main() { + var changedFiles []string + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + if line := scanner.Text(); line != "" { + changedFiles = append(changedFiles, line) + } + } + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "pflichttestgate: Eingabe konnte nicht gelesen werden: %v\n", err) + os.Exit(2) + } + + violations := pflichttestgate.CheckDiff(changedFiles, sensitive) + if len(violations) == 0 { + fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.") + return + } + + fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:") + for _, v := range violations { + fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile) + } + os.Exit(1) +} diff --git a/common/pflichttestgate/gate.go b/common/pflichttestgate/gate.go new file mode 100644 index 00000000..f3a5c7d7 --- /dev/null +++ b/common/pflichttestgate/gate.go @@ -0,0 +1,53 @@ +// Package pflichttestgate erzwingt: jede geänderte Go-Datei in einem sicherheitskritischen +// Bereich muss von einer geänderten oder neuen _test.go-Datei im selben Package begleitet sein. +// Die Bereiche (Pfadmuster) liefert der Aufrufer, die Logik ist für alle Module dieselbe. +package pflichttestgate + +import ( + "path" + "regexp" + "strings" +) + +type Violation struct { + Package string + ChangedFile string +} + +func IsSensitive(file string, patterns []*regexp.Regexp) bool { + if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") { + return false + } + for _, re := range patterns { + if re.MatchString(file) { + return true + } + } + return false +} + +func CheckDiff(changedFiles []string, patterns []*regexp.Regexp) []Violation { + sensitiveByPkg := map[string]string{} + testTouchedPkgs := map[string]bool{} + + for _, f := range changedFiles { + pkg := path.Dir(f) + if strings.HasSuffix(f, "_test.go") { + testTouchedPkgs[pkg] = true + continue + } + if IsSensitive(f, patterns) { + if _, seen := sensitiveByPkg[pkg]; !seen { + sensitiveByPkg[pkg] = f + } + } + } + + var violations []Violation + for pkg, file := range sensitiveByPkg { + if !testTouchedPkgs[pkg] { + violations = append(violations, Violation{Package: pkg, ChangedFile: file}) + } + } + return violations +} diff --git a/common/pflichttestgate/gate_test.go b/common/pflichttestgate/gate_test.go new file mode 100644 index 00000000..9c58cc1b --- /dev/null +++ b/common/pflichttestgate/gate_test.go @@ -0,0 +1,54 @@ +package pflichttestgate + +import ( + "regexp" + "testing" +) + +var testPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(^|/)common/svcauth/`), + regexp.MustCompile(`(^|/)common/tenantregistryclient/`), +} + +func TestCheckDiff_BlocksSensitiveChangeWithoutTest(t *testing.T) { + changed := []string{"common/svcauth/svcauth.go", "common/loadtest/loadtest.go"} + + violations := CheckDiff(changed, testPatterns) + + if len(violations) != 1 { + t.Fatalf("erwartet 1 Verstoß, bekommen %d: %+v", len(violations), violations) + } + if violations[0].Package != "common/svcauth" || violations[0].ChangedFile != "common/svcauth/svcauth.go" { + t.Errorf("unerwarteter Verstoß: %+v", violations[0]) + } +} + +func TestCheckDiff_PassesWhenTestFileAccompaniesChange(t *testing.T) { + changed := []string{"common/svcauth/svcauth.go", "common/svcauth/svcauth_test.go"} + + if v := CheckDiff(changed, testPatterns); len(v) != 0 { + t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v) + } +} + +func TestCheckDiff_TestInOtherPackageDoesNotCount(t *testing.T) { + changed := []string{"common/svcauth/svcauth.go", "common/tenantregistryclient/tenantregistryclient_test.go"} + + if v := CheckDiff(changed, testPatterns); len(v) != 1 { + t.Fatalf("erwartet 1 Verstoß, bekommen %+v", v) + } +} + +func TestCheckDiff_IgnoresNonGoAndUnrelated(t *testing.T) { + changed := []string{"common/svcauth/README.md", "common/httpclient/httpclient.go"} + + if v := CheckDiff(changed, testPatterns); len(v) != 0 { + t.Fatalf("erwartet keinen Verstoß, bekommen %+v", v) + } +} + +func TestCheckDiff_NoPatternsMeansNoSensitiveAreas(t *testing.T) { + if v := CheckDiff([]string{"common/svcauth/svcauth.go"}, nil); len(v) != 0 { + t.Fatalf("ohne Muster darf nichts als sensibel gelten, bekommen %+v", v) + } +} -- 2.47.3