feat(common): parametrisiertes Pflichttest-Gate für common/ #379
No files matched your search
@@ -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
|
||||
+4
-1
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user