okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettest(e2e): run session-scoped Go scenarios in parallel5b3f8be

main
9.0 KiB321 linesraw
1//go:build unix
2
3// Live daemon responses must agree with the published contract. Adapter tests
4// alone cannot detect fields that their decoder ignores or never reads.
5package e2e
6
7import (
8	"bytes"
9	"encoding/json"
10	"fmt"
11	"io"
12	"net/http"
13	"os"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"sync"
18	"testing"
19
20	"github.com/google/jsonschema-go/jsonschema"
21	"go.yaml.in/yaml/v3"
22)
23
24type wireContract struct {
25	document map[string]any
26	mu       sync.Mutex
27	schemas  map[string]*jsonschema.Resolved
28	failures []error
29}
30
31func loadWireContract(root string) (*wireContract, error) {
32	data, err := os.ReadFile(filepath.Join(root, "docs", "openapi.yaml"))
33	if err != nil {
34		return nil, err
35	}
36	var document map[string]any
37	if err := yaml.Unmarshal(data, &document); err != nil {
38		return nil, err
39	}
40	return &wireContract{document: document, schemas: make(map[string]*jsonschema.Resolved)}, nil
41}
42
43func (contract *wireContract) dereference(value map[string]any) map[string]any {
44	if reference, ok := value["$ref"].(string); ok {
45		var current any = contract.document
46		for part := range strings.SplitSeq(strings.TrimPrefix(reference, "#/"), "/") {
47			current = current.(map[string]any)[strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~")]
48		}
49		return current.(map[string]any)
50	}
51	return value
52}
53
54func schemaReferences(value any) any {
55	switch value := value.(type) {
56	case map[string]any:
57		result := make(map[string]any, len(value))
58		for key, child := range value {
59			if key == "$ref" {
60				result[key] = strings.Replace(child.(string), "#/components/schemas/", "#/$defs/", 1)
61			} else {
62				result[key] = schemaReferences(child)
63			}
64		}
65		return result
66	case []any:
67		result := make([]any, len(value))
68		for index, child := range value {
69			result[index] = schemaReferences(child)
70		}
71		return result
72	default:
73		return value
74	}
75}
76
77func (contract *wireContract) validate(schema map[string]any, value any) error {
78	encoded, err := json.Marshal(schema)
79	if err != nil {
80		return err
81	}
82	key := string(encoded)
83	contract.mu.Lock()
84	resolved := contract.schemas[key]
85	if resolved == nil {
86		root := schemaReferences(schema).(map[string]any)
87		root["$defs"] = schemaReferences(contract.document["components"].(map[string]any)["schemas"])
88		root["$schema"] = "https://json-schema.org/draft/2020-12/schema"
89		encoded, err = json.Marshal(root)
90		var parsed jsonschema.Schema
91		if err == nil {
92			err = json.Unmarshal(encoded, &parsed)
93		}
94		if err == nil {
95			resolved, err = parsed.Resolve(nil)
96		}
97		if err == nil {
98			contract.schemas[key] = resolved
99		}
100	}
101	contract.mu.Unlock()
102	if err != nil {
103		return err
104	}
105	return resolved.Validate(value)
106}
107
108func (contract *wireContract) responseSchema(request *http.Request, response *http.Response) (map[string]any, error) {
109	paths := contract.document["paths"].(map[string]any)
110	var operation map[string]any
111	for path, definition := range paths {
112		expected, actual := strings.Split(path, "/"), strings.Split(request.URL.Path, "/")
113		if len(expected) != len(actual) {
114			continue
115		}
116		matches := true
117		for index, segment := range expected {
118			if !strings.HasPrefix(segment, "{") && segment != actual[index] {
119				matches = false
120				break
121			}
122		}
123		if matches {
124			operation, _ = definition.(map[string]any)[strings.ToLower(request.Method)].(map[string]any)
125			break
126		}
127	}
128	if operation == nil {
129		return nil, fmt.Errorf("undocumented operation %s %s", request.Method, request.URL.Path)
130	}
131	responses := operation["responses"].(map[string]any)
132	definition, ok := responses[strconv.Itoa(response.StatusCode)].(map[string]any)
133	if !ok {
134		return nil, fmt.Errorf("undocumented response %s", response.Status)
135	}
136	definition = contract.dereference(definition)
137	content, ok := definition["content"].(map[string]any)
138	if !ok {
139		return nil, nil
140	}
141	mediaType, _, _ := strings.Cut(response.Header.Get("Content-Type"), ";")
142	media, ok := content[mediaType].(map[string]any)
143	if !ok {
144		return nil, fmt.Errorf("undocumented response media type %q", mediaType)
145	}
146	if mediaType == "text/event-stream" {
147		return contract.dereference(media["itemSchema"].(map[string]any)), nil
148	}
149	if mediaType != "application/json" && mediaType != "application/problem+json" {
150		return nil, nil
151	}
152	schema, _ := media["schema"].(map[string]any)
153	return schema, nil
154}
155
156func (contract *wireContract) record(request *http.Request, err error) {
157	if err == nil {
158		return
159	}
160	contract.mu.Lock()
161	defer contract.mu.Unlock()
162	contract.failures = append(contract.failures, fmt.Errorf("%s %s: %w", request.Method, request.URL.Path, err))
163}
164
165type contractTransport struct {
166	upstream http.RoundTripper
167	contract *wireContract
168}
169
170func (transport contractTransport) RoundTrip(request *http.Request) (*http.Response, error) {
171	response, err := transport.upstream.RoundTrip(request)
172	if err != nil {
173		return response, err
174	}
175	schema, err := transport.contract.responseSchema(request, response)
176	transport.contract.record(request, err)
177	if schema == nil || err != nil {
178		return response, nil
179	}
180	response.Body = &contractBody{ReadCloser: response.Body, contract: transport.contract, request: request, schema: schema, stream: strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream")}
181	return response, nil
182}
183
184func (transport contractTransport) CloseIdleConnections() {
185	if closer, ok := transport.upstream.(interface{ CloseIdleConnections() }); ok {
186		closer.CloseIdleConnections()
187	}
188}
189
190type contractBody struct {
191	io.ReadCloser
192	contract   *wireContract
193	request    *http.Request
194	schema     map[string]any
195	pending    []byte
196	stream     bool
197	finished   bool
198	frame      map[string]any
199	data       []string
200	frameBytes int
201}
202
203func (body *contractBody) check(data []byte) {
204	decoder := json.NewDecoder(bytes.NewReader(data))
205	var value any
206	err := decoder.Decode(&value)
207	if err == nil {
208		schema := body.schema
209		if body.stream {
210			data := schema["properties"].(map[string]any)["data"].(map[string]any)
211			schema, _ = data["contentSchema"].(map[string]any)
212			if schema == nil {
213				for _, variant := range data["anyOf"].([]any) {
214					if candidate, ok := variant.(map[string]any)["contentSchema"].(map[string]any); ok {
215						schema = candidate
216						break
217					}
218				}
219			}
220		}
221		err = body.contract.validate(schema, value)
222	}
223	body.contract.record(body.request, err)
224}
225
226// Parse the envelope as well as its JSON payload. Data lines join with newline;
227// comments are legal, while fields forbidden by the contract remain visible.
228func (body *contractBody) streamLine(line []byte) {
229	body.frameBytes += len(line) + 1
230	if body.frameBytes > 1<<20 {
231		body.contract.record(body.request, fmt.Errorf("SSE frame exceeds 1 MiB"))
232		body.pending, body.frame, body.data, body.finished = nil, nil, nil, true
233		return
234	}
235	if len(line) == 0 {
236		if len(body.data) != 0 {
237			body.frame["data"] = strings.Join(body.data, "\n")
238		}
239		if len(body.frame) != 0 {
240			body.contract.record(body.request, body.contract.validate(body.schema, body.frame))
241			if data, ok := body.frame["data"].(string); ok && data != "[DONE]" {
242				body.check([]byte(data))
243			}
244		}
245		body.frame, body.data, body.frameBytes = nil, nil, 0
246		return
247	}
248	if line[0] == ':' {
249		return
250	}
251	name, value, _ := bytes.Cut(line, []byte(":"))
252	value = bytes.TrimPrefix(value, []byte(" "))
253	if body.frame == nil {
254		body.frame = make(map[string]any)
255	}
256	if string(name) == "data" {
257		body.data = append(body.data, string(value))
258	} else {
259		body.frame[string(name)] = string(value)
260	}
261}
262
263func (body *contractBody) Read(target []byte) (int, error) {
264	count, err := body.ReadCloser.Read(target)
265	if !body.finished {
266		body.pending = append(body.pending, target[:count]...)
267		if len(body.pending) > 1<<20 {
268			body.contract.record(body.request, fmt.Errorf("response or SSE frame exceeds 1 MiB"))
269			body.pending, body.finished = nil, true
270		} else if body.stream {
271			for {
272				line, remaining, found := bytes.Cut(body.pending, []byte("\n"))
273				if !found {
274					break
275				}
276				body.streamLine(bytes.TrimSuffix(line, []byte("\r")))
277				if body.finished {
278					break
279				}
280				body.pending = remaining
281			}
282		} else if err == io.EOF {
283			body.check(body.pending)
284			body.pending, body.finished = nil, true
285		}
286	}
287	return count, err
288}
289
290func (body *contractBody) Close() error {
291	if !body.stream && !body.finished && json.Valid(body.pending) {
292		body.check(body.pending)
293		body.finished = true
294	}
295	return body.ReadCloser.Close()
296}
297
298func TestOpenAPIRejectsDriftInRealDaemonResponse(t *testing.T) {
299	t.Parallel()
300	response, err := conn(t).HTTPClient().Get(conn(t).BaseURL() + "/server")
301	if err != nil {
302		t.Fatal(err)
303	}
304	defer response.Body.Close()
305	// This deliberate unauthenticated request is a real problem representation.
306	var problem map[string]any
307	if err := json.NewDecoder(response.Body).Decode(&problem); err != nil {
308		t.Fatal(err)
309	}
310	schema, err := suite.contract.responseSchema(response.Request, response)
311	if err != nil {
312		t.Fatal(err)
313	}
314	if err := suite.contract.validate(schema, problem); err != nil {
315		t.Fatal(err)
316	}
317	delete(problem, "code")
318	if err := suite.contract.validate(schema, problem); err == nil {
319		t.Fatal("contract accepted a problem missing its required code")
320	}
321}