diff --git a/additional_test.go b/additional_test.go new file mode 100644 index 0000000..a717f54 --- /dev/null +++ b/additional_test.go @@ -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") + } +} diff --git a/agent/enrichment_test.go b/agent/enrichment_test.go index 0a3efbc..e0e2dab 100644 --- a/agent/enrichment_test.go +++ b/agent/enrichment_test.go @@ -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. diff --git a/agent/network_test.go b/agent/network_test.go index 8603c6e..2b14ab6 100644 --- a/agent/network_test.go +++ b/agent/network_test.go @@ -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 diff --git a/normalization_test.go b/normalization_test.go new file mode 100644 index 0000000..53d03cb --- /dev/null +++ b/normalization_test.go @@ -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) + } + } +} diff --git a/references.go b/references.go index 9ca3243..c5a6613 100644 --- a/references.go +++ b/references.go @@ -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, ":") { diff --git a/references_test.go b/references_test.go new file mode 100644 index 0000000..8d54007 --- /dev/null +++ b/references_test.go @@ -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") + } +} diff --git a/traversal_test.go b/traversal_test.go new file mode 100644 index 0000000..bc1f7b2 --- /dev/null +++ b/traversal_test.go @@ -0,0 +1,135 @@ +package odp_test + +import ( + "context" + "errors" + "fmt" + "reflect" + "testing" + + odp "github.com/offering-protocol/odp-go" +) + +func TestPageIterationFailureAndEarlyStop(t *testing.T) { + wantErr := errors.New("connection closed") + for _, cancel := range []bool{false, true} { + ctx, stop := context.WithCancel(context.Background()) + if cancel { + stop() + } + calls := 0 + var gotErr error + var items []int + for item, err := range odp.IterateItems(ctx, odp.Page[int]{Items: []int{1}, Next: "/next"}, func(context.Context, string) (odp.Page[int], error) { + calls++ + return odp.Page[int]{}, wantErr + }) { + if err != nil { + gotErr = err + } else { + items = append(items, item) + } + } + stop() + if cancel { + if !errors.Is(gotErr, context.Canceled) || calls != 0 { + t.Fatalf("cancellation: %v, calls %d", gotErr, calls) + } + } else if !errors.Is(gotErr, wantErr) || calls != 1 { + t.Fatalf("loader failure: %v, calls %d", gotErr, calls) + } + if !reflect.DeepEqual(items, []int{1}) { + t.Fatalf("items: %v", items) + } + } + for _, itemsMode := range []bool{false, true} { + load := func(context.Context, string) (odp.Page[int], error) { + t.Fatal("loaded after consumer stopped") + return odp.Page[int]{}, nil + } + first := odp.Page[int]{Items: []int{1, 2}, Next: "/next"} + if itemsMode { + for range odp.IterateItems(context.Background(), first, load) { + break + } + } else { + for range odp.IteratePages(context.Background(), first, load) { + break + } + } + } +} + +func TestPageIterationPreservesOrderAndStopsAtEnd(t *testing.T) { + calls := 0 + var got []int + for item, err := range odp.IterateItems(context.Background(), odp.Page[int]{Items: []int{1, 2}, Next: "/next"}, func(_ context.Context, next string) (odp.Page[int], error) { + calls++ + if next != "/next" { + t.Fatalf("next = %s", next) + } + return odp.Page[int]{Items: []int{3, 4}}, nil + }) { + if err != nil { + t.Fatal(err) + } + got = append(got, item) + } + if calls != 1 || !reflect.DeepEqual(got, []int{1, 2, 3, 4}) { + t.Fatalf("calls %d, items %v", calls, got) + } +} + +func TestPageIterationBoundsUnendingTraversal(t *testing.T) { + calls, pages, failures := 0, 0, 0 + for _, err := range odp.IteratePages(context.Background(), odp.Page[int]{Next: "/0"}, func(context.Context, string) (odp.Page[int], error) { + calls++ + return odp.Page[int]{Next: fmt.Sprintf("/%d", calls)}, nil + }) { + if err != nil { + failures++ + } else { + pages++ + } + } + if pages != odp.MaxTraversalPages || failures != 1 || calls > odp.MaxTraversalPages { + t.Fatalf("pages %d, failures %d, calls %d", pages, failures, calls) + } +} + +func TestResourceIdentityConstruction(t *testing.T) { + for _, kind := range []odp.ResourceType{odp.ResourceCollection, odp.ResourceOffering} { + identity, err := odp.NewResourceIdentity("https://EXAMPLE.com:443/.well-known/odp", kind, "search") + if err != nil { + t.Fatal(err) + } + if identity.Service != "https://example.com" || identity.ID != "search" || identity.Type != kind || identity.Key() != "https://example.com\x00"+string(kind)+"\x00search" { + t.Fatalf("identity = %#v", identity) + } + } + for _, tc := range []struct { + origin string + kind odp.ResourceType + id string + }{ + {"https://example.com", odp.ResourceOffering, "../search"}, + {"https://example.com", "future", "search"}, + {"http://example.com", odp.ResourceOffering, "search"}, + } { + if _, err := odp.NewResourceIdentity(tc.origin, tc.kind, tc.id); err == nil { + t.Fatalf("accepted %#v", tc) + } + } + for _, tc := range []struct { + value odp.Optional[int] + want int + present bool + }{ + {odp.Optional[int]{}, 0, false}, {odp.Null[int](), 0, false}, {odp.Some(0), 0, true}, {odp.Some(42), 42, true}, + } { + got, ok := tc.value.Get() + if got != tc.want || ok != tc.present { + t.Fatalf("Get = %d, %v; want %d, %v", got, ok, tc.want, tc.present) + } + } +} diff --git a/validation.go b/validation.go index 9f2c564..4aa4220 100644 --- a/validation.go +++ b/validation.go @@ -278,7 +278,8 @@ func normalizeBranding(document map[string]any) { image, imageOK := branding[member].(map[string]any) imageType, typeOK := image["type"].(string) if imageOK && typeOK && imageType != "image/png" && imageType != "image/svg+xml" && imageType != "image/webp" { - delete(branding, member) + delete(document, "branding") + return } else if imageOK { for key := range image { if key != "src" && key != "type" { @@ -691,6 +692,9 @@ func issue(path, keyword, message string) ValidationIssue { } func validLanguageTag(value string) bool { + if strings.Contains(value, "_") { + return false + } if _, err := language.Parse(value); err != nil { return false } diff --git a/validation_boundaries_test.go b/validation_boundaries_test.go new file mode 100644 index 0000000..bc083ee --- /dev/null +++ b/validation_boundaries_test.go @@ -0,0 +1,121 @@ +package odp_test + +import ( + "encoding/json" + "errors" + "testing" + + odp "github.com/offering-protocol/odp-go" +) + +func TestAgentBrandingFallbackParsesWholeService(t *testing.T) { + for _, member := range []string{"icon", "logo"} { + document := map[string]any{ + "odp_version": "1.0", "name": "Example", "description": "Catalog", "language": "en", "localizations": []string{"en"}, + "http": map[string]any{"endpoint_base": "/odp"}, + "operations": []any{map[string]any{"name": "get-offering", "authentication": "not-required"}, map[string]any{"name": "list-offerings", "authentication": "not-required"}}, + "branding": map[string]any{"icon": map[string]any{"src": "/icon", "type": "image/png"}, "logo": map[string]any{"src": "/logo", "type": "image/png"}}, + } + document["branding"].(map[string]any)[member].(map[string]any)["type"] = "image/future" + data, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + got, err := odp.ParseAgentServiceDocument(data) + if err != nil || got.Name != "Example" || got.Branding != nil { + t.Fatalf("%s: got %#v, %v", member, got, err) + } + if _, err := odp.ParseServiceDocument(data); err == nil { + t.Fatal("strict parser accepted unknown branding") + } + } +} + +func TestResourceRepresentationValidationBoundaries(t *testing.T) { + for _, resource := range []string{"collection", "offering"} { + for _, tc := range []struct { + name string + fields map[string]any + valid bool + }{ + {"invalid language", map[string]any{"language": "not_a_language"}, false}, + {"underscore locale", map[string]any{"language": "en_US"}, false}, + {"invalid syntax", map[string]any{"language": "en-!"}, false}, + {"invalid localization", map[string]any{"localizations": []string{"en", "not_a_language"}}, false}, + {"case duplicates", map[string]any{"localizations": []string{"en", "EN"}}, false}, + {"missing representation language", map[string]any{"language": "de", "localizations": []string{"en"}}, false}, + {"private language", map[string]any{"language": "x-private", "localizations": []string{"x-private"}}, true}, + {"case match", map[string]any{"language": "en", "localizations": []string{"EN"}}, true}, + {"duplicate images", map[string]any{"images": []any{map[string]any{"src": "/icon", "alt": "One"}, map[string]any{"src": "/icon", "alt": "Two"}}}, false}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + document := map[string]any{"odp_version": "1.0", "id": "search", "name": "Search"} + for key, value := range tc.fields { + document[key] = value + } + data, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + if resource == "collection" { + _, err = odp.ParseCollection(data) + } else { + _, err = odp.ParseOffering(data) + } + if (err == nil) != tc.valid { + t.Fatalf("valid %v: %v", tc.valid, err) + } + if err != nil { + var validation *odp.ValidationError + if !errors.As(err, &validation) || len(validation.Issues) == 0 || validation.Error() == "" { + t.Fatalf("missing validation details: %v", err) + } + } + }) + } + } +} + +func TestFilterTypeOperatorValidation(t *testing.T) { + for _, kind := range []string{"string", "boolean", "integer"} { + for _, operator := range []string{"eq", "gt", "gte", "lt", "lte"} { + data, err := json.Marshal(map[string]any{"id": "size", "title": "Size", "description": "Requested size", "type": kind, "operators": []string{operator}}) + if err != nil { + t.Fatal(err) + } + _, err = odp.ParseFilterDefinition(data) + wantValid := kind == "integer" || operator == "eq" + if (err == nil) != wantValid { + t.Fatalf("%s %s: %v", kind, operator, err) + } + } + } + if _, err := odp.ParseFilterDefinition([]byte(`{"id":"available","title":"Available","description":"Availability","type":"boolean","operators":["eq"],"unit":{"system":"service","code":"flag"}}`)); err == nil { + t.Fatal("boolean filter accepted a unit") + } +} + +func TestServiceSearchCapabilityRequiresOperation(t *testing.T) { + for _, search := range []bool{false, true} { + document := odp.ServiceDocument{ODPVersion: "1.0", Name: "Example", Description: "Catalog", Language: "en", Localizations: []string{"en"}, HTTP: odp.HTTPConfiguration{EndpointBase: "/odp"}, Operations: []odp.OperationDescriptor{{Name: odp.OperationGetOffering, Authentication: "not-required"}, {Name: odp.OperationListOfferings, Authentication: "not-required"}}, SearchCapabilities: &odp.SearchCapabilities{Filters: &odp.FilterCapabilitySource{Linked: &odp.CapabilityLink{Href: "/filters"}}}} + if search { + document.Operations = append(document.Operations, odp.OperationDescriptor{Name: odp.OperationSearchOfferings, Authentication: "not-required"}) + } + data, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + _, err = odp.ParseServiceDocument(data) + if (err == nil) != search { + t.Fatalf("search=%v: %v", search, err) + } + } +} + +func TestParserRejectsTrailingJSON(t *testing.T) { + for _, suffix := range []string{" {}", " {", " true"} { + if _, err := odp.ParseCollection([]byte(`{"odp_version":"1.0","id":"search","name":"Search"}` + suffix)); err == nil { + t.Fatalf("accepted suffix %s", suffix) + } + } +}