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