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
476 lines
17 KiB
Go
476 lines
17 KiB
Go
package contracts
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// Helpers -------------------------------------------------------------------
|
|
|
|
func makeContract(role Role, repo, contractID string, meta map[string]any) Contract {
|
|
return Contract{
|
|
ID: contractID,
|
|
Type: ContractHTTP,
|
|
Role: role,
|
|
RepoPrefix: repo,
|
|
Meta: meta,
|
|
}
|
|
}
|
|
|
|
func buildRegistry(contracts ...Contract) *Registry {
|
|
r := NewRegistry()
|
|
for _, c := range contracts {
|
|
r.Add(c)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// shapeMap is a trivial in-memory ShapeLookup keyed by symbol ID.
|
|
type shapeMap map[string]*Shape
|
|
|
|
func (sm shapeMap) lookup(id string) *Shape { return sm[id] }
|
|
|
|
func findIssue(t *testing.T, issues []ContractIssue, kind string, field string) *ContractIssue {
|
|
t.Helper()
|
|
for i := range issues {
|
|
if issues[i].Kind == kind && issues[i].Field == field {
|
|
return &issues[i]
|
|
}
|
|
}
|
|
kinds := make([]string, 0, len(issues))
|
|
for _, is := range issues {
|
|
kinds = append(kinds, is.Kind+"("+is.Field+")")
|
|
}
|
|
t.Fatalf("missing %s(%s) in %v", kind, field, kinds)
|
|
return nil
|
|
}
|
|
|
|
// Tests ---------------------------------------------------------------------
|
|
|
|
func TestValidate_OrphanConsumer(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleConsumer, "web", "http::GET::/ghost", map[string]any{}),
|
|
)
|
|
got := Validate(reg, nil)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 issue, got %d", len(got))
|
|
}
|
|
if got[0].Kind != IssueOrphanConsumer {
|
|
t.Errorf("kind = %q", got[0].Kind)
|
|
}
|
|
if got[0].Severity != SeverityWarning {
|
|
t.Errorf("severity = %q, want warning", got[0].Severity)
|
|
}
|
|
}
|
|
|
|
func TestValidate_OrphanProvider(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/health", map[string]any{}),
|
|
)
|
|
got := Validate(reg, nil)
|
|
if len(got) != 1 {
|
|
t.Fatalf("want 1 issue, got %d", len(got))
|
|
}
|
|
if got[0].Kind != IssueOrphanProvider || got[0].Severity != SeverityInfo {
|
|
t.Errorf("kind=%s severity=%s", got[0].Kind, got[0].Severity)
|
|
}
|
|
}
|
|
|
|
// Response: provider removed a field that consumer still reads — breaking.
|
|
func TestValidate_Response_FieldRemoved_Breaking(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/users", map[string]any{
|
|
"response_type": "api/resp.go::UserResp",
|
|
}),
|
|
makeContract(RoleConsumer, "web", "http::GET::/users", map[string]any{
|
|
"response_type": "web/types.ts::UserResp",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/resp.go::UserResp": {
|
|
Kind: "struct",
|
|
Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
},
|
|
},
|
|
"web/types.ts::UserResp": {
|
|
Kind: "interface",
|
|
Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
{Name: "email", Type: "string", Required: true}, // consumer still expects it
|
|
},
|
|
},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueResponseFieldRemoved, "email")
|
|
if iss.Severity != SeverityBreaking {
|
|
t.Errorf("severity = %q, want breaking", iss.Severity)
|
|
}
|
|
}
|
|
|
|
// Response: consumer optionally reads a missing field — downgraded warning.
|
|
func TestValidate_Response_FieldRemoved_Optional_Warning(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/users", map[string]any{
|
|
"response_type": "api/resp.go::UserResp",
|
|
}),
|
|
makeContract(RoleConsumer, "web", "http::GET::/users", map[string]any{
|
|
"response_type": "web/types.ts::UserResp",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/resp.go::UserResp": {
|
|
Fields: []ShapeField{{Name: "id", Type: "string", Required: true}},
|
|
},
|
|
"web/types.ts::UserResp": {
|
|
Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
{Name: "nickname", Type: "string", Required: false},
|
|
},
|
|
},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueResponseFieldRemoved, "nickname")
|
|
if iss.Severity != SeverityWarning {
|
|
t.Errorf("severity = %q, want warning (optional field absent)", iss.Severity)
|
|
}
|
|
}
|
|
|
|
// Response: provider emits extra field consumer doesn't read — info.
|
|
func TestValidate_Response_FieldAdded_Info(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/x", map[string]any{"response_type": "api/resp.go::X"}),
|
|
makeContract(RoleConsumer, "web", "http::GET::/x", map[string]any{"response_type": "web/resp.ts::X"}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/resp.go::X": {Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
{Name: "notes", Type: "string", Required: false},
|
|
}},
|
|
"web/resp.ts::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueResponseFieldAdded, "notes")
|
|
if iss.Severity != SeverityInfo {
|
|
t.Errorf("severity = %q, want info", iss.Severity)
|
|
}
|
|
}
|
|
|
|
// Request: provider requires a field the consumer doesn't send — breaking.
|
|
func TestValidate_Request_RequiredFieldMissing_Breaking(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::POST::/users", map[string]any{
|
|
"request_type": "api/req.go::CreateUser",
|
|
}),
|
|
makeContract(RoleConsumer, "web", "http::POST::/users", map[string]any{
|
|
"request_type": "web/req.ts::CreateUser",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/req.go::CreateUser": {Fields: []ShapeField{
|
|
{Name: "email", Type: "string", Required: true},
|
|
{Name: "password", Type: "string", Required: true},
|
|
}},
|
|
"web/req.ts::CreateUser": {Fields: []ShapeField{
|
|
{Name: "email", Type: "string", Required: true},
|
|
// missing password
|
|
}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueRequestFieldRequired, "password")
|
|
if iss.Severity != SeverityBreaking {
|
|
t.Errorf("severity = %q, want breaking", iss.Severity)
|
|
}
|
|
}
|
|
|
|
// Request: consumer sends a field the provider doesn't accept — info.
|
|
func TestValidate_Request_ExtraField_Info(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::POST::/x", map[string]any{"request_type": "api/req.go::X"}),
|
|
makeContract(RoleConsumer, "web", "http::POST::/x", map[string]any{"request_type": "web/req.ts::X"}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/req.go::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}},
|
|
"web/req.ts::X": {Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
{Name: "debug", Type: "boolean"},
|
|
}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueRequestFieldExtra, "debug")
|
|
if iss.Severity != SeverityInfo {
|
|
t.Errorf("severity = %q, want info", iss.Severity)
|
|
}
|
|
}
|
|
|
|
// Shared field with different types across provider / consumer.
|
|
func TestValidate_TypeChanged_Breaking(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/thing", map[string]any{"response_type": "api/resp.go::Thing"}),
|
|
makeContract(RoleConsumer, "web", "http::GET::/thing", map[string]any{"response_type": "web/resp.ts::Thing"}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/resp.go::Thing": {Fields: []ShapeField{{Name: "id", Type: "int64", Required: true}}},
|
|
"web/resp.ts::Thing": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
iss := findIssue(t, issues, IssueResponseFieldTypeChanged, "id")
|
|
if iss.Severity != SeverityBreaking {
|
|
t.Errorf("severity = %q, want breaking", iss.Severity)
|
|
}
|
|
if iss.Details == "" {
|
|
t.Error("expected details with provider/consumer types")
|
|
}
|
|
}
|
|
|
|
// Shape comparison is tolerant across language boundaries:
|
|
//
|
|
// - Nullable-optional variants: `*User` (Go) ≡ `User | null` (TS) ≡ `User | None` (Py).
|
|
// - Collection variants: `[]User` ≡ `Array<User>` ≡ `List<User>` ≡ `User[]`.
|
|
// - Package qualifier: `api.User` ≡ `User`.
|
|
// - Cross-language primitive aliases: `string` ≡ `String` ≡ `str`,
|
|
// `bool` ≡ `Boolean`, etc.
|
|
//
|
|
// Otherwise every cross-language contract would look broken.
|
|
func TestValidate_TypesCompatible_AcrossLanguages(t *testing.T) {
|
|
cases := [][2]string{
|
|
{"*User", "User | null"},
|
|
{"User", "User | None"},
|
|
{"[]User", "Array<User>"},
|
|
{"List<User>", "User[]"},
|
|
{"api.User", "User"},
|
|
{"string", "String"}, // Go / Dart
|
|
{"string", "str"}, // Go / Python
|
|
{"bool", "Boolean"}, // Go / Java / Kotlin
|
|
{"int64", "Long"}, // Go / Java
|
|
{"float64", "Double"}, // Go / Java / Dart
|
|
{"time.Time", "DateTime"}, // Go / Dart
|
|
{"time.Time", "Instant"}, // Go / Java (java.time)
|
|
{"time.Time", "datetime"}, // Go / Python
|
|
{"time.Time", "OffsetDateTime"}, // Go / Java
|
|
{"DateTime", "Instant"}, // Dart / Java
|
|
{"time.Duration", "Duration"}, // Go / Dart / Java
|
|
{"time.Duration", "timedelta"}, // Go / Python
|
|
{"[]byte", "Uint8List"}, // Go / Dart
|
|
{"[]byte", "bytes"}, // Go / Python
|
|
{"UUID", "Uuid"}, // Python / Rust
|
|
}
|
|
for _, c := range cases {
|
|
if !canAssign(c[0], c[1]) {
|
|
t.Errorf("canAssign(%q, %q) = false, want true (symmetric alias case)", c[0], c[1])
|
|
}
|
|
if !canAssign(c[1], c[0]) {
|
|
t.Errorf("canAssign(%q, %q) = false, want true (symmetric alias case)", c[1], c[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Directional numeric compatibility: int widens to float (JSON
|
|
// numbers coerce), float → int truncates and is breaking.
|
|
func TestValidate_CanAssign_NumericWidening(t *testing.T) {
|
|
if !canAssign("float", "int") {
|
|
t.Error("canAssign(float, int) = false, want true (JSON numbers widen)")
|
|
}
|
|
if canAssign("int", "float") {
|
|
t.Error("canAssign(int, float) = true, want false (truncation risk)")
|
|
}
|
|
if !canAssign("Double", "int32") {
|
|
t.Error("canAssign(Double, int32) across languages failed")
|
|
}
|
|
if canAssign("int64", "Double") {
|
|
t.Error("canAssign(int64, Double) should fail — float → int is lossy")
|
|
}
|
|
}
|
|
|
|
// `any` / `interface{}` / `Dynamic` / `object` are wire-level
|
|
// universals. A slot typed `any` can hold anything. A slot typed `X`
|
|
// can NOT safely hold `any` — you'd be trusting unchecked data.
|
|
func TestValidate_CanAssign_AnyType(t *testing.T) {
|
|
if !canAssign("any", "User") {
|
|
t.Error("canAssign(any, User) = false, want true")
|
|
}
|
|
if !canAssign("interface{}", "int") {
|
|
t.Error("canAssign(interface{}, int) = false, want true")
|
|
}
|
|
if canAssign("User", "any") {
|
|
t.Error("canAssign(User, any) = true, want false (can't trust unchecked data)")
|
|
}
|
|
if canAssign("int", "Dynamic") {
|
|
t.Error("canAssign(int, Dynamic) = true, want false")
|
|
}
|
|
}
|
|
|
|
// Same field on both sides where the provider emits a string and
|
|
// the consumer reads the same thing spelled differently by language.
|
|
// Previously this was a `response_field_type_changed` breaking issue.
|
|
// Now it's compatible and emits no issue.
|
|
func TestValidate_Response_PrimitiveAlias_NoIssue(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "core-api", "http::GET::/x", map[string]any{
|
|
"response_type": "core-api/resp.go::Thing",
|
|
}),
|
|
makeContract(RoleConsumer, "tuck_app", "http::GET::/x", map[string]any{
|
|
"response_type": "tuck_app/resp.dart::Thing",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"core-api/resp.go::Thing": {Fields: []ShapeField{
|
|
{Name: "tuckId", Type: "string", Required: true},
|
|
{Name: "count", Type: "int", Required: true},
|
|
}},
|
|
"tuck_app/resp.dart::Thing": {Fields: []ShapeField{
|
|
{Name: "tuckId", Type: "String", Required: true},
|
|
// Consumer reads a float even though provider sends int —
|
|
// widening is safe.
|
|
{Name: "count", Type: "double", Required: true},
|
|
}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
for _, is := range issues {
|
|
if is.Kind == IssueResponseFieldTypeChanged {
|
|
t.Errorf("unexpected response_field_type_changed for field %q: %s", is.Field, is.Details)
|
|
}
|
|
}
|
|
}
|
|
|
|
// UUIDs and bytes serialise as strings on the wire, so a `str` slot
|
|
// can hold them — but the reverse isn't safe. The validator should
|
|
// treat these widening directions and flag the narrow ones.
|
|
func TestValidate_CanAssign_WireString_WideningOnly(t *testing.T) {
|
|
// Wide: str can hold UUID / bytes (serialised form is a string).
|
|
if !canAssign("string", "UUID") {
|
|
t.Error("canAssign(string, UUID) = false, want true (UUID serialises as string)")
|
|
}
|
|
if !canAssign("String", "[]byte") {
|
|
t.Error("canAssign(String, []byte) = false, want true (bytes serialise as base64 string)")
|
|
}
|
|
// Narrow: a string slot accepting an arbitrary string on the
|
|
// consumer side when the provider declared a UUID — the consumer
|
|
// might receive a non-UUID and fail at runtime if it expected
|
|
// one. Breaking.
|
|
if canAssign("UUID", "string") {
|
|
t.Error("canAssign(UUID, string) = true, want false (arbitrary strings aren't UUIDs)")
|
|
}
|
|
if canAssign("bytes", "string") {
|
|
t.Error("canAssign(bytes, string) = true, want false (arbitrary strings aren't base64)")
|
|
}
|
|
}
|
|
|
|
// Realistic cross-language contract: provider is Go (time.Time,
|
|
// []byte, UUID), consumer is Dart (DateTime, Uint8List, String).
|
|
// None of these should flag as breaking.
|
|
func TestValidate_Response_CrossLangTemporal_NoIssue(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "core-api", "http::GET::/x", map[string]any{
|
|
"response_type": "core-api/resp.go::Thing",
|
|
}),
|
|
makeContract(RoleConsumer, "tuck_app", "http::GET::/x", map[string]any{
|
|
"response_type": "tuck_app/resp.dart::Thing",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"core-api/resp.go::Thing": {Fields: []ShapeField{
|
|
{Name: "createdAt", Type: "time.Time", Required: true},
|
|
{Name: "ttl", Type: "time.Duration", Required: true},
|
|
{Name: "sessionId", Type: "uuid.UUID", Required: true},
|
|
{Name: "payload", Type: "[]byte", Required: true},
|
|
}},
|
|
"tuck_app/resp.dart::Thing": {Fields: []ShapeField{
|
|
{Name: "createdAt", Type: "DateTime", Required: true},
|
|
{Name: "ttl", Type: "Duration", Required: true},
|
|
// Consumer types sessionId as String — UUID serialises
|
|
// that way and the slot accepts it. Not breaking.
|
|
{Name: "sessionId", Type: "String", Required: true},
|
|
{Name: "payload", Type: "String", Required: true}, // base64
|
|
}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
for _, is := range issues {
|
|
if is.Kind == IssueResponseFieldTypeChanged {
|
|
t.Errorf("unexpected type-change on %q: provider=%s consumer=%s — %s",
|
|
is.Field, is.ProviderType, is.ConsumerType, is.Details)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request direction: provider expects int, consumer sends float →
|
|
// breaking. The widening rule is direction-sensitive, and a value
|
|
// that's "compatible" as a response read isn't as a request write.
|
|
func TestValidate_Request_NarrowingBreaks(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::POST::/x", map[string]any{
|
|
"request_type": "api/req.go::X",
|
|
}),
|
|
makeContract(RoleConsumer, "web", "http::POST::/x", map[string]any{
|
|
"request_type": "web/req.ts::X",
|
|
}),
|
|
)
|
|
shapes := shapeMap{
|
|
"api/req.go::X": {Fields: []ShapeField{{Name: "n", Type: "int", Required: true}}},
|
|
"web/req.ts::X": {Fields: []ShapeField{{Name: "n", Type: "float", Required: true}}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
found := false
|
|
for _, is := range issues {
|
|
if is.Kind == IssueRequestFieldTypeChanged && is.Field == "n" {
|
|
found = true
|
|
if is.Severity != SeverityBreaking {
|
|
t.Errorf("want breaking, got %s", is.Severity)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected request_field_type_changed for n (float→int narrowing)")
|
|
}
|
|
}
|
|
|
|
// Missing shape on one side → warning that diff was skipped, not breaking.
|
|
func TestValidate_TypeUnknown_Warning(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::GET::/x", map[string]any{"response_type": "api/resp.go::X"}),
|
|
makeContract(RoleConsumer, "web", "http::GET::/x", map[string]any{"response_type": "web/resp.ts::X"}),
|
|
)
|
|
// Only provider shape is known; consumer type is a symbol ID we
|
|
// can't resolve (cross-repo, not re-indexed).
|
|
shapes := shapeMap{
|
|
"api/resp.go::X": {Fields: []ShapeField{{Name: "id", Type: "string", Required: true}}},
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
found := false
|
|
for _, is := range issues {
|
|
if is.Kind == IssueResponseTypeUnknown && is.Severity == SeverityWarning {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected response_type_unknown warning, got %#v", issues)
|
|
}
|
|
}
|
|
|
|
// Matched pair with identical shapes → zero issues.
|
|
func TestValidate_MatchingShapes_NoIssues(t *testing.T) {
|
|
reg := buildRegistry(
|
|
makeContract(RoleProvider, "api", "http::POST::/users", map[string]any{
|
|
"request_type": "api/req.go::User",
|
|
"response_type": "api/resp.go::User",
|
|
}),
|
|
makeContract(RoleConsumer, "web", "http::POST::/users", map[string]any{
|
|
"request_type": "web/req.ts::User",
|
|
"response_type": "web/resp.ts::User",
|
|
}),
|
|
)
|
|
shape := &Shape{Fields: []ShapeField{
|
|
{Name: "id", Type: "string", Required: true},
|
|
{Name: "email", Type: "string", Required: true},
|
|
}}
|
|
shapes := shapeMap{
|
|
"api/req.go::User": shape,
|
|
"api/resp.go::User": shape,
|
|
"web/req.ts::User": shape,
|
|
"web/resp.ts::User": shape,
|
|
}
|
|
issues := Validate(reg, shapes.lookup)
|
|
if len(issues) != 0 {
|
|
t.Errorf("want 0 issues, got %d: %#v", len(issues), issues)
|
|
}
|
|
}
|