package httpapi import ( "go/ast" "go/parser" "go/token" "os" "sort" "strconv" "strings" "testing" ) // contractGoldenFile ist die eingefrorene Endpunktliste. const contractGoldenFile = "contract_routes.txt" // TestAPIContractIsFrozen haelt den API-Vertrag fest (Phase 22). // // Der Vertrag ist eingefroren: `/api/v1` ist ausgeliefert, und jede Aenderung // daran bricht bestehende Aufrufer. Ein Endpunkt, der still verschwindet oder // eine andere Berechtigung bekommt, faellt niemandem auf, bis ein Kunde anruft // — genau deshalb steht er hier in einer Datei, die man **absichtlich** aendern // muss. // // Geprueft wird der Quelltext des Routers, nicht der gebaute Multiplexer: // http.ServeMux gibt seine Routen nicht heraus, und ein Test, der nur die // bekannten Routen anfragt, bemerkt eine **hinzugefuegte** nicht. Eine neue // Route ist aber genau die haeufigste Vertragsaenderung. // // Bricht dieser Test, ist das keine Panne, sondern die Frage: Ist die Aenderung // abwaertskompatibel? Wenn nein, gehoert sie hinter `/api/v2`. func TestAPIContractIsFrozen(testInstance *testing.T) { registeredRoutes := extractRegisteredRoutes(testInstance, "router.go") goldenContent, readError := os.ReadFile(contractGoldenFile) if readError != nil { testInstance.Fatalf("die eingefrorene Endpunktliste ließ sich nicht lesen: %v", readError) } expectedRoutes := parseGoldenRoutes(string(goldenContent)) reportRouteDifferences(testInstance, expectedRoutes, registeredRoutes) } // reportRouteDifferences nennt jede Abweichung einzeln. // // Eine reine "stimmt nicht ueberein"-Meldung zwaenge zum Vergleichen von Hand; // bei knapp hundert Endpunkten sucht das niemand freiwillig. func reportRouteDifferences(testInstance *testing.T, expectedRoutes []string, actualRoutes []string) { testInstance.Helper() expectedSet := make(map[string]bool, len(expectedRoutes)) for _, singleRoute := range expectedRoutes { expectedSet[singleRoute] = true } actualSet := make(map[string]bool, len(actualRoutes)) for _, singleRoute := range actualRoutes { actualSet[singleRoute] = true } for _, singleRoute := range actualRoutes { if !expectedSet[singleRoute] { testInstance.Errorf("neuer oder geänderter Endpunkt: %s\n"+ " Ist er abwärtskompatibel? Dann in %s eintragen. Wenn nicht, gehört er hinter /api/v2.", singleRoute, contractGoldenFile) } } for _, singleRoute := range expectedRoutes { if !actualSet[singleRoute] { testInstance.Errorf("entfallener Endpunkt: %s\n"+ " Ein ausgelieferter Endpunkt darf nicht verschwinden — bestehende Aufrufer brechen.", singleRoute) } } } // extractRegisteredRoutes liest die Routen aus dem Quelltext des Routers. // // Jede Zeile hat die Form " ". Die Berechtigung // gehoert dazu, weil eine stillschweigend gelockerte Pruefung die gefaehrlichste // Vertragsaenderung ueberhaupt waere: Der Endpunkt funktioniert weiter, nur // duerfen ihn ploetzlich mehr Leute aufrufen. func extractRegisteredRoutes(testInstance *testing.T, sourceFileName string) []string { testInstance.Helper() fileSet := token.NewFileSet() parsedFile, parseError := parser.ParseFile(fileSet, sourceFileName, nil, parser.SkipObjectResolution) if parseError != nil { testInstance.Fatalf("der Router ließ sich nicht auswerten: %v", parseError) } collectedRoutes := make([]string, 0, 128) ast.Inspect(parsedFile, func(currentNode ast.Node) bool { callExpression, isCall := currentNode.(*ast.CallExpr) if !isCall { return true } methodName, isMultiplexerCall := multiplexerMethodName(callExpression) if !isMultiplexerCall || len(callExpression.Args) == 0 { return true } routePattern, patternResolved := resolveRoutePattern(callExpression.Args[0]) if !patternResolved { return true } // Die Auffangroute "/" ist kein fachlicher Endpunkt, sondern die // Fehlerhülle für alles Unbekannte. if routePattern == "/" { return true } requiredPermission := "-" if methodName == "Handle" && len(callExpression.Args) > 1 { requiredPermission = resolveRequiredPermission(callExpression.Args[1]) } collectedRoutes = append(collectedRoutes, routePattern+" "+requiredPermission) return true }) sort.Strings(collectedRoutes) return collectedRoutes } // multiplexerMethodName erkennt einen Aufruf auf dem Router. func multiplexerMethodName(callExpression *ast.CallExpr) (string, bool) { selectorExpression, isSelector := callExpression.Fun.(*ast.SelectorExpr) if !isSelector { return "", false } receiverIdentifier, isIdentifier := selectorExpression.X.(*ast.Ident) if !isIdentifier || receiverIdentifier.Name != "requestMultiplexer" { return "", false } if selectorExpression.Sel.Name != "Handle" && selectorExpression.Sel.Name != "HandleFunc" { return "", false } return selectorExpression.Sel.Name, true } // resolveRoutePattern setzt das Routenmuster aus dem Ausdruck zusammen. // // Die Routen stehen als `"GET " + apiBasePath + "/agents"` im Quelltext; der // Basispfad wird dabei aufgeloest, damit die eingefrorene Liste die // tatsaechlichen Adressen enthaelt und nicht eine Konstante. func resolveRoutePattern(patternExpression ast.Expr) (string, bool) { switch typedExpression := patternExpression.(type) { case *ast.BasicLit: if typedExpression.Kind != token.STRING { return "", false } unquotedValue, unquoteError := strconv.Unquote(typedExpression.Value) if unquoteError != nil { return "", false } return unquotedValue, true case *ast.Ident: if typedExpression.Name == "apiBasePath" { return apiBasePath, true } return "", false case *ast.BinaryExpr: if typedExpression.Op != token.ADD { return "", false } leftValue, leftResolved := resolveRoutePattern(typedExpression.X) rightValue, rightResolved := resolveRoutePattern(typedExpression.Y) if !leftResolved || !rightResolved { return "", false } return leftValue + rightValue, true default: return "", false } } // resolveRequiredPermission liest die geforderte Berechtigung aus dem Aufruf. // // Der übliche Fall ist `protected("agents.read", handler)`. Alles andere — // etwa die Middleware der Agentenanmeldung — wird als solches benannt statt // stillschweigend als "keine Berechtigung" geführt. func resolveRequiredPermission(handlerExpression ast.Expr) string { callExpression, isCall := handlerExpression.(*ast.CallExpr) if !isCall || len(callExpression.Args) == 0 { return "?" } functionIdentifier, isIdentifier := callExpression.Fun.(*ast.Ident) if !isIdentifier { return "?" } if functionIdentifier.Name == "agentAuthenticated" { return "agent-token" } // Angemeldet, aber ohne besondere Berechtigung: die Endpunkte, die jeder // über sich selbst aufruft. Sie als "?" zu führen wäre die schlechteste // Auskunft — eine Unklarheit in einem eingefrorenen Vertrag verdeckt genau // die Änderung, die er aufdecken soll. if functionIdentifier.Name == "authenticated" { return "sitzung" } if functionIdentifier.Name != "protected" { return "?" } permissionLiteral, isLiteral := callExpression.Args[0].(*ast.BasicLit) if !isLiteral || permissionLiteral.Kind != token.STRING { return "?" } unquotedPermission, unquoteError := strconv.Unquote(permissionLiteral.Value) if unquoteError != nil { return "?" } return unquotedPermission } // parseGoldenRoutes liest die eingefrorene Liste. func parseGoldenRoutes(fileContent string) []string { parsedRoutes := make([]string, 0, 128) for _, currentLine := range strings.Split(fileContent, "\n") { trimmedLine := strings.TrimSpace(currentLine) if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") { continue } parsedRoutes = append(parsedRoutes, trimmedLine) } sort.Strings(parsedRoutes) return parsedRoutes }