Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions additional_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package odp_test

import (
"encoding/json"
"reflect"
"testing"

odp "github.com/offering-protocol/odp-go"
)

func TestAdditionalMembersRoundTrip(t *testing.T) {
models := []any{
&odp.ServiceDocument{}, &odp.Collection{}, &odp.Offering{}, &odp.ProblemDetails{},
&odp.FilterDefinition{}, &odp.SortDefinition{}, &odp.PricePreview{}, &odp.SortKey{},
&odp.SearchCapabilities{}, &odp.InvalidParameter{}, &odp.FilterExpression{},
&odp.OfferingSearchRequest{}, &odp.Page[odp.Collection]{}, &odp.OfferingPage[odp.Offering]{},
&odp.HTTPConfiguration{}, &odp.CapabilityLink{}, &odp.FilterUnit{},
&odp.FilterCapabilitySource{}, &odp.SortCapabilitySource{}, &odp.CollectionSearchRequest{},
&odp.RefinementBucket{}, &odp.RefinementGroup{},
}
for _, model := range models {
t.Run(reflect.TypeOf(model).Elem().Name(), func(t *testing.T) {
baseline, err := json.Marshal(model)
if err != nil {
t.Fatal(err)
}
var object map[string]json.RawMessage
if err := json.Unmarshal(baseline, &object); err != nil {
t.Fatal(err)
}
object["future"] = json.RawMessage(`{"integer":9007199254740993,"nested":[null,true,"text"]}`)
input, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(input, model); err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(model)
if err != nil {
t.Fatal(err)
}
var actual map[string]json.RawMessage
if err := json.Unmarshal(encoded, &actual); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, object) {
t.Fatalf("round trip changed members: got %s, want %s", encoded, input)
}
for _, invalid := range []string{`[]`, `{"`, `{"odp_version":[],"id":[],"type":[],"status":[],"direction":[],"filters":[],"name":[],"items":{},"endpoint_base":[],"href":[],"system":[],"inline":{},"count":[],"filter_id":[]}`} {
if err := model.(json.Unmarshaler).UnmarshalJSON([]byte(invalid)); err == nil {
t.Fatalf("accepted malformed model: %s", invalid)
}
after, err := json.Marshal(model)
if err != nil || string(after) != string(encoded) {
t.Fatalf("failed decode mutated model: %s, %v", after, err)
}
}
if err := json.Unmarshal(baseline, model); err != nil {
t.Fatal(err)
}
after, err := json.Marshal(model)
if err != nil || string(after) != string(baseline) {
t.Fatalf("replacement retained stale members: %s, %v", after, err)
}
})
}
}

func TestAdditionalMembersCannotOverrideCoreFields(t *testing.T) {
offering := odp.Offering{ID: "search", Additional: odp.AdditionalMembers{
"id": json.RawMessage(`"substitute"`), "description": json.RawMessage(`"injected"`),
"future": json.RawMessage(`true`),
}}
data, err := json.Marshal(offering)
if err != nil {
t.Fatal(err)
}
var object map[string]json.RawMessage
if err := json.Unmarshal(data, &object); err != nil {
t.Fatal(err)
}
if string(object["id"]) != `"search"` || object["description"] != nil || string(object["future"]) != "true" {
t.Fatalf("core fields overwritten: %s", data)
}
offering.Additional["future"] = json.RawMessage(`{`)
if _, err := json.Marshal(offering); err == nil {
t.Fatal("accepted invalid extension JSON")
}
if _, err := json.Marshal(odp.RefinementBucket{Value: make(chan int)}); err == nil {
t.Fatal("accepted unencodable bucket")
}
}
12 changes: 12 additions & 0 deletions agent/enrichment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ func TestOpenAPIDocumentsAreValidatedBeforeUse(t *testing.T) {
}
}

func TestOpenAPIPathExtensionsDoNotHideOperations(t *testing.T) {
document := strings.Replace(openAPIDocument, `"paths":{`, `"paths":{"x-summary":"Catalog operations",`, 1)
client, base := enrichmentClient(t, offeringJSON(""), "", map[string]string{"/openapi.json": document})
resolved, operation, err := client.resolveOpenAPI(t.Context(), base+"/openapi.json", "rent")
if err != nil {
t.Fatal(err)
}
if operation["operationId"] != "rent" || resolved["paths"].(map[string]any)["x-summary"] != "Catalog operations" {
t.Fatalf("resolved wrong operation or lost extension: %#v, %#v", resolved, operation)
}
}

func TestAttributeSchemaGraphLimits(t *testing.T) {
documents := map[string]string{}
// A chain deeper than the eight reference levels the protocol allows.
Expand Down
15 changes: 15 additions & 0 deletions agent/network_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
package agent

import (
"net/http"
"net/netip"
"testing"
)

func TestSecureDialRejectsMissingPort(t *testing.T) {
client := secureHTTPClient(false)
transport := client.Transport.(*http.Transport)
t.Cleanup(transport.CloseIdleConnections)
connection, err := transport.DialContext(t.Context(), "tcp", "example.com")
if connection != nil {
connection.Close()
t.Fatal("malformed address opened a connection")
}
if err == nil {
t.Fatal("accepted address without a port")
}
}

func TestPublicAddressClassification(t *testing.T) {
tests := []struct {
address string
Expand Down
74 changes: 74 additions & 0 deletions normalization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package odp_test

import (
"encoding/json"
"reflect"
"testing"

odp "github.com/offering-protocol/odp-go"
)

func TestAgentNormalizationPreservesUsableCapabilities(t *testing.T) {
for _, tc := range []struct{ name, kind, input, want string }{
{"branding extensions", "service-document", `{"branding":{"future":true,"icon":{"src":"/i","future":1},"logo":{"src":"/l","type":"image/png","future":2}}}`, `{"branding":{"icon":{"src":"/i"},"logo":{"src":"/l","type":"image/png"}}}`},
{"unknown branding icon", "service-document", `{"branding":{"icon":{"src":"/i","type":"future"},"logo":{"src":"/l","type":"image/png"}}}`, `{}`},
{"unknown branding logo", "service-document", `{"branding":{"icon":{"src":"/i","type":"image/png"},"logo":{"src":"/l","type":"future"}}}`, `{}`},
{"empty branding", "service-document", `{"branding":{"future":true}}`, `{}`},
{"payment options", "service-document", `{"protocols":{"payments":[{"name":"mpp","authentication":"required","options":["future","inflow"]},{"name":"x402","authentication":"optional","options":["future"]}]}}`, `{"protocols":{"payments":[{"name":"mpp","authentication":"required","options":["inflow"]},{"name":"x402","authentication":"optional"}]}}`},
{"images", "collection", `{"images":[{"src":"/a","future":true},{"src":"/b","type":"future"},null]}`, `{"images":[{"src":"/a"},null]}`},
{"filter types", "filter-page", `{"items":[{"id":"known","type":"integer"},{"type":"future"},{"operators":["future"]},{"unit":{"system":"future"}},null]}`, `{"items":[{"id":"known","type":"integer"},null]}`},
{"filter vocabulary", "filter-page", `{"items":[{"type":"string","operators":["eq","exists","in"],"unit":{"system":"service"}},{"type":"decimal","operators":["gt","gte","lt","lte"],"unit":{"system":"ucum"}}]}`, `{"items":[{"type":"string","operators":["eq","exists","in"],"unit":{"system":"service"}},{"type":"decimal","operators":["gt","gte","lt","lte"],"unit":{"system":"ucum"}}]}`},
{"sort vocabulary", "sort-page", `{"items":[{"keys":[{"direction":"ascending","missing":"last"}]},{"keys":[{"direction":"descending","missing":"first"}]},{"keys":[{"direction":"future"}]},{"keys":[{"missing":"future"}]},{"keys":[null]},{}]}`, `{"items":[{"keys":[{"direction":"ascending","missing":"last"}]},{"keys":[{"direction":"descending","missing":"first"}]},{"keys":[null]},{}]}`},
{"inline definitions", "collection", `{"search_capabilities":{"filters":{"inline":[{"type":"future"},{"type":"integer"},null]},"sorts":{"inline":[{"keys":[{"direction":"future"}]}]}}}`, `{"search_capabilities":{"filters":{"inline":[{"type":"integer"},null]}}}`},
{"empty capabilities", "offering", `{"search_capabilities":{"filters":{"inline":[{"type":"future"}]}}}`, `{}`},
{"linked capabilities", "service-document", `{"search_capabilities":{"filters":{"linked":{"href":"/filters"}}}}`, `{"search_capabilities":{"filters":{"linked":{"href":"/filters"}}}}`},
{"problem locations", "problem", `{"invalid_params":[{"in":"body"},{"in":"header"},{"in":"path"},{"in":"query"},{"in":"future"},null,{}]}`, `{"invalid_params":[{"in":"body"},{"in":"header"},{"in":"path"},{"in":"query"},null,{}]}`},
{"future price and schema", "offering", `{"price":{"type":"future"},"schema":{"url":"/schema","future":true},"id":"search"}`, `{"id":"search"}`},
{"action boundaries", "offering", `{"actions":[{"id":"keep","rel":"future","http":{"method":"POST","href":"/run"}},{"authentication":"future"},{"future":true},{"http":{"future":true}},{"http":{"request":{"future":true}}},{"http":{"request":{"schema":{"future":true}}}},{"openapi":{"future":true}},{"http":{"method":"DELETE"}}]}`, `{"actions":[{"id":"keep","rel":"future","http":{"method":"POST","href":"/run"}}]}`},
{"empty actions", "offering", `{"actions":[{"http":{"method":"PATCH"}}]}`, `{}`},
{"nested offerings", "offering-page", `{"items":[{"id":"one","price":{"type":"future"}},{"id":"two","price":{"type":"free"}},null]}`, `{"items":[{"id":"one"},{"id":"two","price":{"type":"free"}},null]}`},
{"nested collections", "collection-page", `{"items":[{"images":[{"src":"/x","type":"future"}]}]}`, `{"items":[{}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
input := []byte(tc.input)
got, err := odp.NormalizeAgentResponse(input, tc.kind)
if err != nil {
t.Fatal(err)
}
var actual, expected any
if err := json.Unmarshal(got, &actual); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(tc.want), &expected); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, expected) {
t.Fatalf("got %s, want %s", got, tc.want)
}
if string(input) != tc.input {
t.Fatal("mutated caller input")
}
})
}
}

func TestAgentNormalizationLeavesMalformedValuesForValidation(t *testing.T) {
for _, kind := range []string{"service-document", "collection", "offering", "filter-page", "sort-page", "problem", "collection-page", "offering-page"} {
t.Run(kind, func(t *testing.T) {
for _, input := range []string{`{}`, `{"items":null}`, `{"items":42}`, `{"invalid_params":null}`} {
got, err := odp.NormalizeAgentResponse([]byte(input), kind)
if err != nil || string(got) != input {
t.Fatalf("%s: got %s, %v", input, got, err)
}
}
})
}
for _, input := range []string{`{`, `[]`, `true`} {
if _, err := odp.NormalizeAgentResponse([]byte(input), "service-document"); err == nil {
t.Fatalf("accepted %s", input)
}
if _, err := odp.ParseAgentServiceDocument([]byte(input)); err == nil {
t.Fatalf("parsed %s", input)
}
}
}
10 changes: 7 additions & 3 deletions references.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,13 @@ func ResolveContinuation(reference, serviceOrigin string) (*url.URL, error) {
}

func canonicalOrigin(value *url.URL) (string, error) {
host, err := idna.Lookup.ToASCII(value.Hostname())
if err != nil {
return "", fmt.Errorf("normalize ODP host: %w", err)
host := value.Hostname()
if net.ParseIP(host) == nil {
var err error
host, err = idna.Lookup.ToASCII(host)
if err != nil {
return "", fmt.Errorf("normalize ODP host: %w", err)
}
}
host = strings.ToLower(host)
if strings.Contains(host, ":") {
Expand Down
88 changes: 88 additions & 0 deletions references_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package odp_test

import (
"testing"

odp "github.com/offering-protocol/odp-go"
)

func TestOriginAndReferenceBoundaries(t *testing.T) {
for _, input := range []string{"https://%", "https://user:secret@example.com", "/relative", "http://example.com", "https://\u200d.example"} {
if _, err := odp.DeriveServiceOrigin(input); err == nil {
t.Fatalf("accepted origin %q", input)
}
}
for _, tc := range []struct{ input, want string }{
{"https://EXAMPLE.com:443/document", "https://example.com"},
{"https://example.com:8443/document", "https://example.com:8443"},
{"http://localhost:80/document", "http://localhost"},
{"http://[::1]:9900/document", "http://[::1]:9900"},
{"https://[2001:db8::1]:443/document", "https://[2001:db8::1]"},
{"https://bücher.example/document", "https://xn--bcher-kva.example"},
} {
got, err := odp.DeriveServiceOrigin(tc.input)
if err != nil || got != tc.want {
t.Fatalf("%q = %q, %v", tc.input, got, err)
}
}
for _, tc := range []struct{ ref, origin string }{
{"//evil.example/a", "https://example.com"}, {"relative", "https://example.com"},
{"/a", "https://%"}, {"/%", "https://example.com"},
{"/a#fragment", "https://example.com"}, {"https://user:secret@example.com/a", "https://example.com"},
{"http://localhost.evil.example/a", "https://example.com"}, {"/a", ""},
} {
if _, err := odp.ResolveResourceReference(tc.ref, tc.origin); err == nil {
t.Fatalf("accepted %#v", tc)
}
if _, err := odp.ResolveContinuation(tc.ref, tc.origin); err == nil {
t.Fatalf("accepted continuation %#v", tc)
}
}
for _, origin := range []string{"https://example.com", "https://\u200d.example"} {
if _, err := odp.ResolveContinuation("https://\u200d.example/a", origin); err == nil {
t.Fatal("accepted invalid IDNA origin")
}
}
if _, err := odp.ResolveContinuation("https://example.com/a", "https://\u200d.example"); err == nil {
t.Fatal("accepted invalid base origin")
}
if _, err := odp.ResolveContinuation("https://other.example/a", "https://example.com"); err == nil {
t.Fatal("accepted cross-origin continuation")
}
got, err := odp.ResolveContinuation("https://EXAMPLE.com:443/a?cursor=opaque", "https://example.com")
if err != nil || got.RawQuery != "cursor=opaque" {
t.Fatalf("same origin continuation = %v, %v", got, err)
}
}

func TestEveryOperationURL(t *testing.T) {
for _, tc := range []struct {
op odp.Operation
id, path string
}{
{odp.OperationListCollections, "", "/collections"}, {odp.OperationSearchCollections, "", "/collections/search"},
{odp.OperationGetCollection, "plants", "/collections/plants"}, {odp.OperationListCollectionOfferings, "plants", "/collections/plants/offerings"},
{odp.OperationListOfferings, "", "/offerings"}, {odp.OperationSearchOfferings, "", "/offerings/search"},
{odp.OperationGetOffering, "search", "/offerings/search"},
} {
got, err := odp.BuildOperationURL("/odp/", tc.op, "https://example.com", tc.id)
if err != nil || got.String() != "https://example.com/odp"+tc.path {
t.Fatalf("%s: %v, %v", tc.op, got, err)
}
invalidID := "unexpected"
if tc.id != "" {
invalidID = "../escape"
}
if _, err := odp.BuildOperationURL("/odp", tc.op, "https://example.com", invalidID); err == nil {
t.Fatalf("accepted invalid id for %s", tc.op)
}
}
for _, base := range []string{"odp", "//evil.example"} {
if _, err := odp.BuildOperationURL(base, odp.OperationListOfferings, "https://example.com", ""); err == nil {
t.Fatalf("accepted base %s", base)
}
}
if _, err := odp.BuildOperationURL("/odp", "future", "https://example.com", ""); err == nil {
t.Fatal("accepted unknown operation")
}
}
Loading