Skip to content

Commit 16cf990

Browse files
pbeckhamclaude
andcommitted
test(controls): add registry-driven OpenAPI contract test
Guard against drift between the CLI's hand-written request payload structs and the Kosli API by fetching the live OpenAPI schema from the test server and asserting, per registered case, that every json field the CLI sends exists in the schema component and every required schema property is sent by the CLI. Optional schema properties the CLI does not expose are logged, not failed. Seeded with create control (ControlPostInput) and create environment (CreateEnvironmentPutInput) to prove the registry generalises beyond controls; adding an entity is a one-line registry change (#5742). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b0c2b04 commit 16cf990

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

cmd/kosli/openapiContract_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"reflect"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
"github.com/stretchr/testify/suite"
13+
)
14+
15+
// OpenAPIContractTestSuite guards against drift between the CLI's hand-written
16+
// request payload structs and the Kosli API's OpenAPI schema. It fetches the
17+
// live schema from the test server (the same image the integration tests run
18+
// against) and, for each registered case, asserts that:
19+
//
20+
// - every json field the CLI struct sends exists as a property in the schema
21+
// component (so the CLI never sends a field the server would reject — the
22+
// control/env inputs are declared extra="forbid"), and
23+
// - every property the schema marks required is present in the CLI struct (so
24+
// the CLI can always satisfy the server's required fields).
25+
//
26+
// Optional schema properties the CLI does not (yet) expose are allowed and only
27+
// logged — that is a deliberate coverage gap, not drift.
28+
//
29+
// To extend coverage, add one line to the registry in TestPayloadsMatchSchema.
30+
type OpenAPIContractTestSuite struct {
31+
suite.Suite
32+
schema openAPISchema
33+
}
34+
35+
type openAPISchema struct {
36+
Components struct {
37+
Schemas map[string]componentSchema `json:"schemas"`
38+
} `json:"components"`
39+
}
40+
41+
type componentSchema struct {
42+
Properties map[string]json.RawMessage `json:"properties"`
43+
Required []string `json:"required"`
44+
}
45+
46+
// driftCase maps a CLI payload struct to the OpenAPI component it must match.
47+
type driftCase struct {
48+
name string
49+
payload interface{}
50+
component string
51+
// ignore lists json field names to skip on both sides, for deliberate
52+
// CLI/API divergences (none needed yet).
53+
ignore []string
54+
}
55+
56+
func (suite *OpenAPIContractTestSuite) SetupSuite() {
57+
resp, err := http.Get("http://localhost:8001/api/v2/openapi.json")
58+
require.NoError(suite.T(), err, "should fetch the OpenAPI schema from the test server")
59+
defer resp.Body.Close()
60+
body, err := io.ReadAll(resp.Body)
61+
require.NoError(suite.T(), err)
62+
require.NoError(suite.T(), json.Unmarshal(body, &suite.schema), "OpenAPI schema should be valid JSON")
63+
require.NotEmpty(suite.T(), suite.schema.Components.Schemas, "OpenAPI schema should contain components")
64+
}
65+
66+
func (suite *OpenAPIContractTestSuite) TestPayloadsMatchSchema() {
67+
registry := []driftCase{
68+
{name: "create control", payload: ControlPayload{}, component: "ControlPostInput"},
69+
{name: "create environment", payload: CreateEnvironmentPayload{}, component: "CreateEnvironmentPutInput"},
70+
}
71+
72+
for _, c := range registry {
73+
suite.Run(c.name, func() {
74+
t := suite.T()
75+
component, ok := suite.schema.Components.Schemas[c.component]
76+
require.True(t, ok, "OpenAPI component %q not found — it may have been renamed or removed", c.component)
77+
78+
structFields := jsonFieldNames(c.payload)
79+
80+
// 1. Every field the CLI sends must exist in the schema.
81+
for _, field := range structFields {
82+
if contains(c.ignore, field) {
83+
continue
84+
}
85+
_, exists := component.Properties[field]
86+
require.True(t, exists,
87+
"CLI struct %T sends field %q which is not a property of OpenAPI component %q — schema drift",
88+
c.payload, field, c.component)
89+
}
90+
91+
// 2. Every required schema property must be covered by the CLI struct.
92+
for _, req := range component.Required {
93+
if contains(c.ignore, req) {
94+
continue
95+
}
96+
require.Contains(t, structFields, req,
97+
"OpenAPI component %q requires %q but CLI struct %T does not send it — schema drift",
98+
c.component, req, c.payload)
99+
}
100+
101+
// Surface (without failing) optional schema properties the CLI does
102+
// not expose yet, so the coverage gap is visible.
103+
for prop := range component.Properties {
104+
if !contains(structFields, prop) && !contains(c.ignore, prop) {
105+
t.Logf("note: OpenAPI component %q has property %q not exposed by CLI struct %T", c.component, prop, c.payload)
106+
}
107+
}
108+
})
109+
}
110+
}
111+
112+
// jsonFieldNames returns the wire names from a struct's json tags.
113+
func jsonFieldNames(v interface{}) []string {
114+
t := reflect.TypeOf(v)
115+
names := []string{}
116+
for i := 0; i < t.NumField(); i++ {
117+
tag := t.Field(i).Tag.Get("json")
118+
name := strings.Split(tag, ",")[0]
119+
if name == "" || name == "-" {
120+
continue
121+
}
122+
names = append(names, name)
123+
}
124+
return names
125+
}
126+
127+
func contains(list []string, s string) bool {
128+
for _, item := range list {
129+
if item == s {
130+
return true
131+
}
132+
}
133+
return false
134+
}
135+
136+
func TestOpenAPIContractTestSuite(t *testing.T) {
137+
suite.Run(t, new(OpenAPIContractTestSuite))
138+
}

0 commit comments

Comments
 (0)