a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
445 lines
15 KiB
Go
445 lines
15 KiB
Go
package contracts
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Each test builds a minimal source file with a route and a handler,
|
|
// asks the extractor for contracts, and asserts on the schema-shape
|
|
// Meta keys. We only check the enriched fields — framework-routing
|
|
// coverage lives in http_test.go.
|
|
|
|
func TestHTTPEnrich_Go_NetHTTP_Stdlib_FullSchema(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
type CreateReq struct {
|
|
Name string ` + "`json:\"name\"`" + `
|
|
}
|
|
|
|
type CreateResp struct {
|
|
ID string ` + "`json:\"id\"`" + `
|
|
}
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /users", createUser)
|
|
}
|
|
|
|
func createUser(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
name := r.URL.Query().Get("tenant")
|
|
_ = name
|
|
resp := CreateResp{ID: "x"}
|
|
w.WriteHeader(http.StatusCreated)
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
`)
|
|
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 15, EndLine: 17},
|
|
{ID: "pkg/api.go::createUser", Name: "createUser", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 19, EndLine: 30},
|
|
{ID: "pkg/api.go::CreateReq", Name: "CreateReq", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::CreateResp", Name: "CreateResp", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 11, EndLine: 13},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::POST::/users", RoleProvider)
|
|
assertMetaString(t, c, "request_type", "pkg/api.go::CreateReq")
|
|
assertMetaString(t, c, "response_type", "pkg/api.go::CreateResp")
|
|
assertMetaStrings(t, c, "query_params", []string{"tenant"})
|
|
assertMetaInts(t, c, "status_codes", []int{201, 400})
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_Gin_RequestAndResponse(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "github.com/gin-gonic/gin"
|
|
|
|
type LoginReq struct{ Email string }
|
|
type LoginResp struct{ Token string }
|
|
|
|
func register(r *gin.Engine) {
|
|
r.POST("/login", login)
|
|
}
|
|
|
|
func login(c *gin.Context) {
|
|
var req LoginReq
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(400, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
resp := LoginResp{Token: "t"}
|
|
c.JSON(200, resp)
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::login", Name: "login", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 11, EndLine: 20},
|
|
{ID: "pkg/api.go::LoginReq", Name: "LoginReq", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 5, EndLine: 5},
|
|
{ID: "pkg/api.go::LoginResp", Name: "LoginResp", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 6, EndLine: 6},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::POST::/login", RoleProvider)
|
|
assertMetaString(t, c, "request_type", "pkg/api.go::LoginReq")
|
|
assertMetaString(t, c, "response_type", "pkg/api.go::LoginResp")
|
|
assertMetaInts(t, c, "status_codes", []int{200, 400})
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_Fiber_Binding(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "github.com/gofiber/fiber/v2"
|
|
|
|
type CreateTuckReq struct{ Body string }
|
|
|
|
func register(app *fiber.App) {
|
|
app.POST("/v1/tucks", createTuck)
|
|
}
|
|
|
|
func createTuck(c *fiber.Ctx) error {
|
|
var req CreateTuckReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
limit := c.Query("limit")
|
|
_ = limit
|
|
return c.JSON(req)
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::createTuck", Name: "createTuck", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 11, EndLine: 19},
|
|
{ID: "pkg/api.go::CreateTuckReq", Name: "CreateTuckReq", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 5, EndLine: 5},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::POST::/v1/tucks", RoleProvider)
|
|
assertMetaString(t, c, "request_type", "pkg/api.go::CreateTuckReq")
|
|
assertMetaStrings(t, c, "query_params", []string{"limit"})
|
|
assertMetaInts(t, c, "status_codes", []int{400})
|
|
// response is the same type as request in this fixture.
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
// Map-envelope response recursion still resolves when the inner
|
|
// identifier has a syntactic type we can read (Stage 1 scope).
|
|
// Cases where the inner identifier comes from a method-call return
|
|
// (e.g. `workspaces, err := h.svc.List(...)`) belong to the
|
|
// graph-aware `resolveCallReturnTypes` post-pass covered in the
|
|
// indexer test suite.
|
|
func TestHTTPEnrich_Go_RespondJSONEnvelope_SyntacticInner(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "net/http"
|
|
|
|
type Workspace struct { ID string }
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/workspaces", h.ListWorkspaces)
|
|
}
|
|
|
|
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|
ws := &Workspace{}
|
|
respondJSON(w, http.StatusOK, map[string]interface{}{"data": ws})
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::Handler.ListWorkspaces", Name: "ListWorkspaces", Kind: graph.KindMethod, FilePath: "pkg/api.go", StartLine: 11, EndLine: 14},
|
|
{ID: "pkg/api.go::Workspace", Name: "Workspace", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 5, EndLine: 5},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
c := findContract(t, cs, "http::GET::/v1/workspaces", RoleProvider)
|
|
|
|
assertMetaString(t, c, "response_type", "pkg/api.go::Workspace")
|
|
assertMetaInts(t, c, "status_codes", []int{200})
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
// Multi-key envelope: every key surfaces on response_envelope, with
|
|
// the source expression preserved per row. The keys whose values
|
|
// resolve to a typed binding get a "type"; the rest carry just the
|
|
// expression, which is still vastly more useful than the raw
|
|
// WriteJSON call previously stamped onto response_expr.
|
|
// Regression: a value like `[]any{evt1, evt2}` used to be captured
|
|
// by the multi-key envelope regex as `[]any{` (truncated at the
|
|
// first inner `}`), producing a malformed expr field in the
|
|
// dashboard. The brace/bracket-balanced splitter keeps the value
|
|
// expression intact and recognises the slice composite literal.
|
|
func TestHTTPEnrich_Go_EnvelopeValueIsNestedSliceLiteral(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "net/http"
|
|
|
|
type Event struct{ Name string }
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/activity", logActivity)
|
|
}
|
|
|
|
func logActivity(w http.ResponseWriter, r *http.Request) {
|
|
WriteJSON(w, http.StatusOK, map[string]any{"events": []any{Event{}, Event{}}})
|
|
}
|
|
|
|
func WriteJSON(w http.ResponseWriter, code int, body any) {}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::logActivity", Name: "logActivity", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 11, EndLine: 13},
|
|
{ID: "pkg/api.go::Event", Name: "Event", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 5, EndLine: 5},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
c := findContract(t, cs, "http::GET::/v1/activity", RoleProvider)
|
|
|
|
env, ok := c.Meta["response_envelope"].([]map[string]any)
|
|
if !ok || len(env) != 1 {
|
|
t.Fatalf("response_envelope = %#v, want one row", c.Meta["response_envelope"])
|
|
}
|
|
row := env[0]
|
|
expr, _ := row["expr"].(string)
|
|
if expr == "" || strings.HasSuffix(expr, "{") {
|
|
t.Errorf("expr is truncated: %q", expr)
|
|
}
|
|
if !strings.Contains(expr, "[]any{") || !strings.HasSuffix(expr, "}") {
|
|
t.Errorf("expr missing the full literal; got %q", expr)
|
|
}
|
|
if got, _ := row["type"].(string); got == "" {
|
|
t.Errorf("type empty for inline slice literal; want a recognised element type")
|
|
}
|
|
if r, _ := row["repeated"].(bool); !r {
|
|
t.Errorf("repeated=false; want true for []any{...}")
|
|
}
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_RespondJSONEnvelope_MultiKey(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "net/http"
|
|
|
|
type FileEntry struct{ Path string }
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/overlay/sessions/{id}/files", h.handleOverlayList)
|
|
}
|
|
|
|
func (h *Handler) handleOverlayList(w http.ResponseWriter, r *http.Request) {
|
|
out := []FileEntry{}
|
|
total := 0
|
|
WriteJSON(w, http.StatusOK, map[string]any{"files": out, "total": total})
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 7, EndLine: 9},
|
|
{ID: "pkg/api.go::Handler.handleOverlayList", Name: "handleOverlayList", Kind: graph.KindMethod, FilePath: "pkg/api.go", StartLine: 11, EndLine: 15},
|
|
{ID: "pkg/api.go::FileEntry", Name: "FileEntry", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 5, EndLine: 5},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
c := findContract(t, cs, "http::GET::/v1/overlay/sessions/{p1}/files", RoleProvider)
|
|
|
|
env, ok := c.Meta["response_envelope"].([]map[string]any)
|
|
if !ok || len(env) != 2 {
|
|
t.Fatalf("response_envelope = %#v, want 2 rows", c.Meta["response_envelope"])
|
|
}
|
|
byName := map[string]map[string]any{}
|
|
for _, row := range env {
|
|
name, _ := row["name"].(string)
|
|
byName[name] = row
|
|
}
|
|
if files := byName["files"]; files == nil {
|
|
t.Errorf("envelope missing 'files' row; got %v", byName)
|
|
} else if expr, _ := files["expr"].(string); expr != "out" {
|
|
t.Errorf("envelope['files'].expr = %q, want \"out\"", expr)
|
|
}
|
|
if total := byName["total"]; total == nil {
|
|
t.Errorf("envelope missing 'total' row; got %v", byName)
|
|
} else if expr, _ := total["expr"].(string); expr != "total" {
|
|
t.Errorf("envelope['total'].expr = %q, want \"total\"", expr)
|
|
}
|
|
// The previous behavior stamped the entire WriteJSON(...) call
|
|
// as response_expr. With the envelope present we keep just the
|
|
// value literal so the raw view is short.
|
|
if got, _ := c.Meta["response_expr"].(string); strings.HasPrefix(got, "WriteJSON(") {
|
|
t.Errorf("response_expr leaks the full WriteJSON wrapper: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_PathParams_AlwaysPresent(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import "net/http"
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/workspaces/{wid}/tags/{id}", getTag)
|
|
}
|
|
|
|
func getTag(w http.ResponseWriter, r *http.Request) {}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 5, EndLine: 7},
|
|
{ID: "pkg/api.go::getTag", Name: "getTag", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 9, EndLine: 9},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::GET::/v1/workspaces/{p1}/tags/{p2}", RoleProvider)
|
|
// path_params are derived from the normalised template (positional
|
|
// names). The user-written {wid} / {id} slots are rewritten to
|
|
// {p1} / {p2} so cross-repo contract IDs match even when provider
|
|
// and consumer teams picked different names.
|
|
assertMetaStrings(t, c, "path_params", []string{"p1", "p2"})
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_UnresolvedType_PartialSchema(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
func register(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /x", createX)
|
|
}
|
|
|
|
func createX(w http.ResponseWriter, r *http.Request) {
|
|
var req UnknownThing
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::register", Name: "register", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 8, EndLine: 10},
|
|
{ID: "pkg/api.go::createX", Name: "createX", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 12, EndLine: 15},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::POST::/x", RoleProvider)
|
|
// Type name wasn't in the file's node list, so we keep the bare
|
|
// name and flag the meta so a later module-wide pass can resolve
|
|
// it.
|
|
assertMetaString(t, c, "request_type", "UnknownThing")
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
func TestHTTPEnrich_Go_Consumer_MarshalAndDecode(t *testing.T) {
|
|
src := []byte(`package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
)
|
|
|
|
type CreateTuckReq struct{ Title string }
|
|
type CreateTuckResp struct{ ID string }
|
|
|
|
func callCreate() error {
|
|
req := CreateTuckReq{Title: "a"}
|
|
data, _ := json.Marshal(req)
|
|
r, err := http.NewRequest("POST", "http://api/v1/tucks", bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var out CreateTuckResp
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
return nil
|
|
}
|
|
`)
|
|
nodes := []*graph.Node{
|
|
{ID: "pkg/api.go::callCreate", Name: "callCreate", Kind: graph.KindFunction, FilePath: "pkg/api.go", StartLine: 11, EndLine: 25},
|
|
{ID: "pkg/api.go::CreateTuckReq", Name: "CreateTuckReq", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 9, EndLine: 9},
|
|
{ID: "pkg/api.go::CreateTuckResp", Name: "CreateTuckResp", Kind: graph.KindType, FilePath: "pkg/api.go", StartLine: 10, EndLine: 10},
|
|
}
|
|
cs := (&HTTPExtractor{}).Extract("pkg/api.go", src, nodes, nil)
|
|
|
|
c := findContract(t, cs, "http::POST::/v1/tucks", RoleConsumer)
|
|
assertMetaString(t, c, "request_type", "pkg/api.go::CreateTuckReq")
|
|
assertMetaString(t, c, "response_type", "pkg/api.go::CreateTuckResp")
|
|
assertMetaString(t, c, "schema_source", "extracted")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func findContract(t *testing.T, cs []Contract, id string, role Role) Contract {
|
|
t.Helper()
|
|
for _, c := range cs {
|
|
if c.ID == id && c.Role == role {
|
|
return c
|
|
}
|
|
}
|
|
ids := make([]string, 0, len(cs))
|
|
for _, c := range cs {
|
|
ids = append(ids, string(c.Role)+" "+c.ID)
|
|
}
|
|
t.Fatalf("contract %s (%s) not found. have: %v", id, role, ids)
|
|
return Contract{}
|
|
}
|
|
|
|
func assertMetaString(t *testing.T, c Contract, key, want string) {
|
|
t.Helper()
|
|
got, ok := c.Meta[key].(string)
|
|
if !ok {
|
|
t.Errorf("meta[%q] missing or not string on %s (got %T = %v)", key, c.ID, c.Meta[key], c.Meta[key])
|
|
return
|
|
}
|
|
if got != want {
|
|
t.Errorf("meta[%q] on %s = %q, want %q", key, c.ID, got, want)
|
|
}
|
|
}
|
|
|
|
func assertMetaStrings(t *testing.T, c Contract, key string, want []string) {
|
|
t.Helper()
|
|
got, ok := c.Meta[key].([]string)
|
|
if !ok {
|
|
t.Errorf("meta[%q] missing or wrong type on %s: %T", key, c.ID, c.Meta[key])
|
|
return
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("meta[%q] on %s = %v, want %v", key, c.ID, got, want)
|
|
return
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("meta[%q] on %s = %v, want %v", key, c.ID, got, want)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertMetaInts(t *testing.T, c Contract, key string, want []int) {
|
|
t.Helper()
|
|
got, ok := c.Meta[key].([]int)
|
|
if !ok {
|
|
t.Errorf("meta[%q] missing or wrong type on %s: %T", key, c.ID, c.Meta[key])
|
|
return
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("meta[%q] on %s = %v, want %v", key, c.ID, got, want)
|
|
return
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("meta[%q] on %s = %v, want %v", key, c.ID, got, want)
|
|
return
|
|
}
|
|
}
|
|
}
|