diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b76dd2bf..13cd3cc72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added +- Added tests for HTTP Range requests, as well as some basic helpers for `AnyOf` and `AllOf`. [PR](https://github.com/ipfs/gateway-conformance/pull/162) ## [0.3.1] - 2023-09-15 ### Added diff --git a/tests/path_gateway_dag_test.go b/tests/path_gateway_dag_test.go index 2c4c9828e..be3e093f7 100644 --- a/tests/path_gateway_dag_test.go +++ b/tests/path_gateway_dag_test.go @@ -6,6 +6,7 @@ import ( "github.com/ipfs/gateway-conformance/tooling" "github.com/ipfs/gateway-conformance/tooling/car" . "github.com/ipfs/gateway-conformance/tooling/check" + "github.com/ipfs/gateway-conformance/tooling/helpers" "github.com/ipfs/gateway-conformance/tooling/ipns" "github.com/ipfs/gateway-conformance/tooling/specs" . "github.com/ipfs/gateway-conformance/tooling/test" @@ -231,86 +232,121 @@ func TestPlainCodec(t *testing.T) { plainCID := plain.Cid() plainOrDagCID := plainOrDag.Cid() - tests := SugarTests{ - { - Name: Fmt(`GET {{name}} without Accept or format= has expected "{{format}}" Content-Type and body as-is`, row.Name, row.Format), - Hint: ` + var dagFormattedResponse []byte + + tests := SugarTests{}. + Append( + helpers.IncludeRandomRangeTests(t, + SugarTest{ + Name: Fmt(`GET {{name}} without Accept or format= has expected "{{format}}" Content-Type and body as-is`, row.Name, row.Format), + Hint: ` No explicit format, just codec in CID `, - Request: Request(). - Path("/ipfs/{{cid}}", plainCID), - Response: Expect(). - Status(200). - Headers( - Header("Content-Disposition"). - Contains(Fmt(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format)), - Header("Content-Type"). - Contains(Fmt("application/{{format}}", row.Format)), - ).Body( + Request: Request(). + Path("/ipfs/{{cid}}", plainCID), + Response: Expect(). + Headers( + Header("Content-Disposition"). + Contains(Fmt(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format)), + ), + }, plain.RawData(), - ), - }, - { - Name: Fmt("GET {{name}} with ?format= has expected {{format}} Content-Type and body as-is", row.Name, row.Format), - Hint: ` + Fmt("application/{{format}}", row.Format), + )...). + Append( + helpers.IncludeRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} with ?format= has expected {{format}} Content-Type and body as-is", row.Name, row.Format), + Hint: ` Explicit format still gives correct output, just codec in CID `, - Request: Request(). - Path("/ipfs/{{cid}}", plainCID). - Query("format", row.Format), - Response: Expect(). - Status(200). - Headers( - Header("Content-Disposition"). - Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format), - Header("Content-Type"). - Contains("application/{{format}}", row.Format), - ).Body( + Request: Request(). + Path("/ipfs/{{cid}}", plainCID). + Query("format", row.Format), + Response: Expect(). + Headers( + Header("Content-Disposition"). + Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format), + ), + }, plain.RawData(), - ), - }, - { - Name: Fmt("GET {{name}} with Accept has expected {{format}} Content-Type and body as-is", row.Name, row.Format), - Hint: ` + Fmt("application/{{format}}", row.Format), + )...). + Append( + helpers.IncludeRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} with Accept has expected {{format}} Content-Type and body as-is, with single range request", row.Name, row.Format), + Hint: ` Explicit format still gives correct output, just codec in CID `, - Request: Request(). - Path("/ipfs/{{cid}}", plainCID). - Header("Accept", Fmt("application/{{format}}", row.Format)), - Response: Expect(). - Status(200). - Headers( - Header("Content-Disposition"). - Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format), - Header("Content-Type"). - Contains("application/{{format}}", row.Format), - ).Body( + Request: Request(). + Path("/ipfs/{{cid}}", plainCID). + Headers( + Header("Accept", Fmt("application/{{format}}", row.Format)), + ), + Response: Expect(). + Headers( + Header("Content-Disposition"). + Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainCID, row.Format), + ), + }, plain.RawData(), - ), - }, - { - Name: Fmt("GET {{name}} with format=dag-{{format}} interprets {{format}} as dag-* variant and produces expected Content-Type and body", row.Name, row.Format), - Hint: ` + Fmt("application/{{format}}", row.Format), + )...). + Append( + SugarTest{ + Name: Fmt("GET {{name}} with format=dag-{{format}} interprets {{format}} as dag-* variant and produces expected Content-Type and body", row.Name, row.Format), + Hint: ` Explicit dag-* format passed, attempt to parse as dag* variant Note: this works only for simple JSON that can be upgraded to DAG-JSON. `, - Request: Request(). - Path("/ipfs/{{cid}}", plainOrDagCID). - Query("format", Fmt("dag-{{format}}", row.Format)), - Response: Expect(). - Status(200). - Headers( - Header("Content-Disposition"). - Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainOrDagCID, row.Format), - Header("Content-Type"). - Contains("application/vnd.ipld.dag-{{format}}", row.Format), - ).Body( - row.Checker(formatted), - ), - }, - } + Request: Request(). + Path("/ipfs/{{cid}}", plainOrDagCID). + Query("format", Fmt("dag-{{format}}", row.Format)), + Response: Expect(). + Status(200). + Headers( + Header("Content-Disposition"). + Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainOrDagCID, row.Format), + Header("Content-Type"). + Contains("application/vnd.ipld.dag-{{format}}", row.Format), + ).Body( + Checks("", func(t []byte) bool { + innerCheck := row.Checker(formatted).Check(t) + if innerCheck.Success { + dagFormattedResponse = t + return true + } + return false + }), + ), + }, + ) RunWithSpecs(t, tests, specs.PathGatewayDAG) + + if dagFormattedResponse != nil { + rangeTests := helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} with format=dag-{{format}} interprets {{format}} as dag-* variant and produces expected Content-Type and body, with single range request", row.Name, row.Format), + Hint: ` + Explicit dag-* format passed, attempt to parse as dag* variant + Note: this works only for simple JSON that can be upgraded to DAG-JSON. + `, + Request: Request(). + Path("/ipfs/{{cid}}", plainOrDagCID). + Query("format", Fmt("dag-{{format}}", row.Format)), + Response: Expect(). + Headers( + Header("Content-Disposition"). + Contains(`{{disposition}}; filename="{{cid}}.{{format}}"`, row.Disposition, plainOrDagCID, row.Format), + ), + }, + dagFormattedResponse, + Fmt("application/vnd.ipld.dag-{{format}}", row.Format), + ) + RunWithSpecs(t, rangeTests, specs.PathGatewayDAG) + } } } @@ -577,9 +613,125 @@ func TestNativeDag(t *testing.T) { ), }, } + tests.Append(helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with no explicit header", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID), + Response: Expect(), + }, + dagTraversal.RawData(), Fmt("application/vnd.ipld.dag-{{format}}", row.Format), + )...).Append( + helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with dag content headers", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-{{format}}", row.Format), + ), + Response: Expect(), + }, + dagTraversal.RawData(), + Fmt("application/vnd.ipld.dag-{{format}}", row.Format), + )...).Append( + helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with non-dag content headers", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID). + Headers( + Header("Accept", "application/{{format}}", row.Format), + ), + Response: Expect(), + }, + dagTraversal.RawData(), + Fmt("application/{{format}}", row.Format), + )...) RunWithSpecs(t, tests, specs.PathGatewayDAG) } + + dagCborFixture := car.MustOpenUnixfsCar("path_gateway_dag/dag-cbor-traversal.car").MustGetRoot() + dagCborCID := dagCborFixture.Cid() + var dagJsonConvertedData []byte + RunWithSpecs(t, SugarTests{ + SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to application/vnd.ipld.dag-json", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-json"), + ), + Response: Expect().Body(Checks("", func(t []byte) bool { + innerCheck := IsJSONEqual(dagCborFixture.Formatted("dag-json")).Check(t) + if innerCheck.Success { + dagJsonConvertedData = t + return true + } + return false + })), + }, + }, specs.PathGatewayDAG) + + if dagJsonConvertedData != nil { + rangeTests := helpers.OnlyRandomRangeTests( + t, + SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to application/vnd.ipld.dag-json with range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-json"), + ), + Response: Expect(), + }, + dagJsonConvertedData, + "application/vnd.ipld.dag-json") + + RunWithSpecs(t, rangeTests, specs.PathGatewayDAG) + } + + var dagCborHTMLRendering []byte + RunWithSpecs(t, SugarTests{ + SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to text/html", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "text/html"), + ), + Response: Expect().Body(Checks("", func(t []byte) bool { + innerCheck := Contains("").Check(string(t)) + if innerCheck.Success { + dagCborHTMLRendering = t + return true + } + return false + })), + }, + }, specs.PathGatewayDAG) + + if dagCborHTMLRendering != nil { + rangeTests := helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to text/html with range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "text/html"), + ), + Response: Expect(), + }, + dagCborHTMLRendering, + "text/html") + + RunWithSpecs(t, rangeTests, specs.PathGatewayDAG) + } } func TestGatewayJSONCborAndIPNS(t *testing.T) { diff --git a/tests/trustless_gateway_raw_test.go b/tests/trustless_gateway_raw_test.go index 2ba80f869..a8c0efaf4 100644 --- a/tests/trustless_gateway_raw_test.go +++ b/tests/trustless_gateway_raw_test.go @@ -5,9 +5,10 @@ import ( "strings" "testing" + "github.com/ipfs/gateway-conformance/tooling/helpers" + "github.com/ipfs/gateway-conformance/tooling" "github.com/ipfs/gateway-conformance/tooling/car" - . "github.com/ipfs/gateway-conformance/tooling/check" "github.com/ipfs/gateway-conformance/tooling/specs" . "github.com/ipfs/gateway-conformance/tooling/test" ) @@ -140,124 +141,19 @@ func TestTrustlessRawRanges(t *testing.T) { // correctly. fixture := car.MustOpenUnixfsCar("gateway-raw-block.car") - var ( - contentType string - contentRange string - ) - - RunWithSpecs(t, SugarTests{ - { - Name: "GET with application/vnd.ipld.raw with single range request includes correct bytes", - Request: Request(). - Path("/ipfs/{{cid}}", fixture.MustGetCid("dir", "ascii.txt")). - Headers( - Header("Accept", "application/vnd.ipld.raw"), - Header("Range", "bytes=6-16"), - ), - Response: Expect(). - Status(206). - Headers( - Header("Content-Type").Contains("application/vnd.ipld.raw"), - Header("Content-Range").Equals("bytes 6-16/31"), - ). - Body(fixture.MustGetRawData("dir", "ascii.txt")[6:17]), - }, - { - Name: "GET with application/vnd.ipld.raw with multiple range request includes correct bytes", + tests := helpers.OnlyRandomRangeTests(t, + SugarTest{ + Name: "GET with application/vnd.ipld.raw with range request includes correct bytes", Request: Request(). Path("/ipfs/{{cid}}", fixture.MustGetCid("dir", "ascii.txt")). Headers( Header("Accept", "application/vnd.ipld.raw"), - Header("Range", "bytes=6-16,0-4"), - ), - Response: Expect(). - Status(206). - Headers( - Header("Content-Type"). - Checks(func(v string) bool { - contentType = v - return v != "" - }), - Header("Content-Range"). - ChecksAll(func(v []string) bool { - if len(v) == 1 { - contentRange = v[0] - } - return true - }), ), + Response: Expect(), }, - }, specs.PathGatewayRaw) - - tests := SugarTests{} - - if strings.Contains(contentType, "application/vnd.ipld.raw") { - // The server is not able to respond to a multi-range request. Therefore, - // there might be only one range or... just the whole file, depending on the headers. - - if contentRange == "" { - // Server does not support range requests and must send back the complete file. - tests = append(tests, SugarTest{ - Name: "GET with application/vnd.ipld.raw with multiple range request includes correct bytes", - Request: Request(). - Path("/ipfs/{{cid}}", fixture.MustGetCid("dir", "ascii.txt")). - Headers( - Header("Accept", "application/vnd.ipld.raw"), - Header("Range", "bytes=6-16,0-4"), - ), - Response: Expect(). - Status(206). - Headers( - Header("Content-Type").Contains("application/vnd.ipld.raw"), - Header("Content-Range").IsEmpty(), - ). - Body(fixture.MustGetRawData("dir", "ascii.txt")), - }) - } else { - // Server supports range requests but only the first range. - tests = append(tests, SugarTest{ - Name: "GET with application/vnd.ipld.raw with multiple range request includes correct bytes", - Request: Request(). - Path("/ipfs/{{cid}}", fixture.MustGetCid("dir", "ascii.txt")). - Headers( - Header("Accept", "application/vnd.ipld.raw"), - Header("Range", "bytes=6-16,0-4"), - ), - Response: Expect(). - Status(206). - Headers( - Header("Content-Type").Contains("application/vnd.ipld.raw"), - Header("Content-Range", "bytes 6-16/31"), - ). - Body(fixture.MustGetRawData("dir", "ascii.txt")[6:17]), - }) - } - } else if strings.Contains(contentType, "multipart/byteranges") { - // The server supports responding with multi-range requests. - tests = append(tests, SugarTest{ - Name: "GET with application/vnd.ipld.raw with multiple range request includes correct bytes", - Request: Request(). - Path("/ipfs/{{cid}}", fixture.MustGetCid("dir", "ascii.txt")). - Headers( - Header("Accept", "application/vnd.ipld.raw"), - Header("Range", "bytes=6-16,0-4"), - ), - Response: Expect(). - Status(206). - Headers( - Header("Content-Type").Contains("multipart/byteranges"), - ). - Body(And( - Contains("Content-Range: bytes 6-16/31"), - Contains("Content-Type: application/vnd.ipld.raw"), - Contains(string(fixture.MustGetRawData("dir", "ascii.txt")[6:17])), - Contains("Content-Range: bytes 0-4/31"), - Contains(string(fixture.MustGetRawData("dir", "ascii.txt")[0:5])), - )), - }) - } else { - t.Error("Content-Type header did not match any of the accepted options") - } + fixture.MustGetRawData("dir", "ascii.txt"), + "application/vnd.ipld.raw", + ) RunWithSpecs(t, tests, specs.TrustlessGatewayRaw) } diff --git a/tooling/helpers/car.go b/tooling/helpers/car.go index 0c745055d..b0d1f784e 100644 --- a/tooling/helpers/car.go +++ b/tooling/helpers/car.go @@ -18,7 +18,11 @@ func StandardCARTestTransforms(t *testing.T, sts test.SugarTests) test.SugarTest } func applyStandardCarResponseHeaders(t *testing.T, st test.SugarTest) test.SugarTest { - st.Response = st.Response.Headers( + resp, ok := st.Response.(test.ExpectBuilder) + if !ok { + t.Fatal("can only apply test transformation on an ExpectBuilder") + } + st.Response = resp.Headers( // TODO: Go always sends Content-Length and it's not possible to explicitly disable the behavior. // For now, we ignore this check. It should be able to be resolved soon: https://github.com/ipfs/boxo/pull/177 // test.Header("Content-Length"). diff --git a/tooling/helpers/range.go b/tooling/helpers/range.go new file mode 100644 index 000000000..b0a6d0e9d --- /dev/null +++ b/tooling/helpers/range.go @@ -0,0 +1,282 @@ +package helpers + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/ipfs/gateway-conformance/tooling/check" + "github.com/ipfs/gateway-conformance/tooling/test" +) + +// parseRange parses a ranges header in the format "bytes=from-to" and returns +// x and y as uint64. +func parseRange(t *testing.T, str string) (from, to uint64) { + if !strings.HasPrefix(str, "bytes=") { + t.Fatalf("byte range %s does not start with 'bytes='", str) + } + + str = strings.TrimPrefix(str, "bytes=") + ranges := strings.Split(str, ",") + if len(ranges) != 1 { + t.Fatalf("byte range %s must have one range", str) + } + + rng := strings.Split(ranges[0], "-") + if len(rng) != 2 { + t.Fatalf("byte range %s is invalid", str) + } + + var err error + from, err = strconv.ParseUint(rng[0], 10, 0) + if err != nil { + t.Fatalf("cannot parse range %s: %s", str, err.Error()) + } + + to, err = strconv.ParseUint(rng[1], 10, 0) + if err != nil { + t.Fatalf("cannot parse range %s: %s", str, err.Error()) + } + + return from, to +} + +// combineRanges combines the multiple request ranges into a single Range header. +func combineRanges(t *testing.T, ranges []string) string { + str := "bytes=" + + for i, rng := range ranges { + from, to := parseRange(t, rng) + str += fmt.Sprintf("%d-%d", from, to) + if i != len(ranges)-1 { + str += "," + } + } + + return str +} + +// SingleRangeTestTransform takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range headers and verifies whether a valid response is given for the requested range. +// +// Note: HTTP Range requests can be validly responded with either the full data, or the requested partial data. +func SingleRangeTestTransform(t *testing.T, baseTest test.SugarTest, byteRange string, fullData []byte) test.SugarTest { + modifiedRequest := baseTest.Request.Clone().Header("Range", byteRange) + if baseTest.Requests != nil { + t.Fatal("does not support multiple requests or responses") + } + modifiedResponse := baseTest.Response.Clone() + + fullSize := int64(len(fullData)) + start, end := parseRange(t, byteRange) + + rangeTest := test.SugarTest{ + Name: baseTest.Name, + Hint: baseTest.Hint, + Request: modifiedRequest, + Requests: nil, + Response: test.AllOf( + modifiedResponse, + test.AnyOf( + test.Expect().Status(http.StatusPartialContent).Body(fullData[start:end+1]).Headers( + test.Header("Content-Range").Equals("bytes {{start}}-{{end}}/{{length}}", start, end, fullSize), + ), + test.Expect().Status(http.StatusOK).Body(fullData), + ), + ), + } + + return rangeTest +} + +// MultiRangeTestTransform takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range or Content-Type headers and verifies whether a valid response is given for the +// requested ranges. +// +// If contentType is empty it is ignored. +// +// Note: HTTP Multi Range requests can be validly responded with one of the full data, the partial data from the first +// range, or the partial data from all the requested ranges. +func MultiRangeTestTransform(t *testing.T, baseTest test.SugarTest, byteRanges []string, fullData []byte, contentType string) test.SugarTest { + modifiedRequest := baseTest.Request.Clone().Header("Range", combineRanges(t, byteRanges)) + if baseTest.Requests != nil { + t.Fatal("does not support multiple requests or responses") + } + modifiedResponse := baseTest.Response.Clone() + + fullSize := int64(len(fullData)) + type rng struct { + start, end uint64 + } + + var multirangeBodyChecks []check.Check[string] + var ranges []rng + for _, r := range byteRanges { + start, end := parseRange(t, r) + ranges = append(ranges, rng{start: start, end: end}) + multirangeBodyChecks = append(multirangeBodyChecks, + check.Contains("Content-Range: bytes {{start}}-{{end}}/{{length}}", ranges[0].start, ranges[0].end, fullSize), + check.Contains(string(fullData[start:end+1])), + ) + } + + rangeTest := test.SugarTest{ + Name: baseTest.Name, + Hint: baseTest.Hint, + Request: modifiedRequest, + Requests: nil, + Response: test.AllOf( + modifiedResponse, + test.AnyOf( + test.Expect().Status(http.StatusOK).Body(fullData).Header(test.Header("Content-Type", contentType)), + test.Expect().Status(http.StatusPartialContent).Body(fullData[ranges[0].start:ranges[0].end+1]).Headers( + test.Header("Content-Range").Equals("bytes {{start}}-{{end}}/{{length}}", ranges[0].start, ranges[0].end, fullSize), + test.Header("Content-Type", contentType), + ), + test.Expect().Status(http.StatusPartialContent).Body( + check.And( + append([]check.Check[string]{check.Contains("Content-Type: {{contentType}}", contentType)}, multirangeBodyChecks...)..., + ), + ).Headers(test.Header("Content-Type").Contains("multipart/byteranges")), + ), + ), + } + + return rangeTest +} + +// IncludeRangeTests takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range headers and verifies whether a valid response is given for the requested ranges. +// Will test the full request, a single range request for the first passed range as well as a multi-range request for +// all the requested ranges. +// +// If contentType is empty it is ignored. +// +// If no ranges are passed, then a panic is produced. +// +// Note: HTTP Range requests can be validly responded with either the full data, or the requested partial data +// Note: HTTP Multi Range requests can be validly responded with one of the full data, the partial data from the first +// range, or the partial data from all the requested ranges +func IncludeRangeTests(t *testing.T, baseTest test.SugarTest, byteRanges []string, fullData []byte, contentType string) test.SugarTests { + if len(byteRanges) == 0 { + panic("byte ranges must be defined") + } + + return includeRangeTests(t, baseTest, byteRanges, fullData, contentType) +} + +// IncludeRandomRangeTests takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range headers and verifies whether a valid response is given for the requested ranges. +// Will test the full request, a single range request for the first passed range as well as a multi-range request for +// all the requested ranges. +// +// If contentType is empty it is ignored. +// +// If no ranges are passed then some non-overlapping ranges are automatically generated for data >= 10 bytes. Smaller +// data will produce a panic to avoid undefined behavior. +// +// Note: HTTP Range requests can be validly responded with either the full data, or the requested partial data +// Note: HTTP Multi Range requests can be validly responded with one of the full data, the partial data from the first +// range, or the partial data from all the requested ranges +func IncludeRandomRangeTests(t *testing.T, baseTest test.SugarTest, fullData []byte, contentType string) test.SugarTests { + return includeRangeTests(t, baseTest, nil, fullData, contentType) +} + +func includeRangeTests(t *testing.T, baseTest test.SugarTest, byteRanges []string, fullData []byte, contentType string) test.SugarTests { + standardBaseRequest := baseTest.Request.Clone() + if contentType != "" { + standardBaseRequest = standardBaseRequest.Header("Content-Type", contentType) + } + standardBase := test.SugarTest{ + Name: fmt.Sprintf("%s - full request", baseTest.Name), + Hint: baseTest.Hint, + Request: standardBaseRequest, + Requests: baseTest.Requests, + Response: test.AllOf( + baseTest.Response, + test.Expect().Status(http.StatusOK).Body(fullData), + ), + Responses: baseTest.Responses, + } + rangeTests := OnlyRangeTests(t, baseTest, byteRanges, fullData, contentType) + return append(test.SugarTests{standardBase}, rangeTests...) +} + +// OnlyRangeTests takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range headers and verifies whether a valid response is given for the requested ranges. +// Will test both a single range request for the first passed range as well as a multi-range request for all the +// requested ranges. +// +// If contentType is empty it is ignored. +// +// If no ranges are passed, then a panic is produced. +// +// Note: HTTP Range requests can be validly responded with either the full data, or the requested partial data +// Note: HTTP Multi Range requests can be validly responded with one of the full data, the partial data from the first +// range, or the partial data from all the requested ranges +func OnlyRangeTests(t *testing.T, baseTest test.SugarTest, byteRanges []string, fullData []byte, contentType string) test.SugarTests { + if len(byteRanges) == 0 { + panic("byte ranges must be defined") + } + + return onlyRangeTests(t, baseTest, byteRanges, fullData, contentType) +} + +// OnlyRandomRangeTests takes a test where there is no "Range" header set in the request, or checks on the +// StatusCode, Body, or Content-Range headers and verifies whether a valid response is given for the requested ranges. +// Will test both a single range request for the first passed range as well as a multi-range request for all the +// requested ranges. +// +// If contentType is empty it is ignored. +// +// If no ranges are passed then some non-overlapping ranges are automatically generated for data >= 10 bytes. Smaller +// data will produce a panic to avoid undefined behavior. +// +// Note: HTTP Range requests can be validly responded with either the full data, or the requested partial data +// Note: HTTP Multi Range requests can be validly responded with one of the full data, the partial data from the first +// range, or the partial data from all the requested ranges +func OnlyRandomRangeTests(t *testing.T, baseTest test.SugarTest, fullData []byte, contentType string) test.SugarTests { + return onlyRangeTests(t, baseTest, nil, fullData, contentType) +} + +func onlyRangeTests(t *testing.T, baseTest test.SugarTest, byteRanges []string, fullData []byte, contentType string) test.SugarTests { + if len(byteRanges) == 0 { + dataLen := len(fullData) + if dataLen < 10 { + panic("transformation not defined for data smaller than 10 bytes") + } + + byteRanges = []string{ + "bytes=7-9", + "bytes=1-3", + } + } + + singleBaseRequest := baseTest.Request.Clone() + if contentType != "" { + singleBaseRequest = singleBaseRequest.Header("Content-Type", contentType) + } + + singleBase := test.SugarTest{ + Name: fmt.Sprintf("%s - single range", baseTest.Name), + Hint: baseTest.Hint, + Request: singleBaseRequest, + Requests: baseTest.Requests, + Response: baseTest.Response, + Responses: baseTest.Responses, + } + singleRange := SingleRangeTestTransform(t, singleBase, byteRanges[0], fullData) + + multiBase := test.SugarTest{ + Name: fmt.Sprintf("%s - multi range", baseTest.Name), + Hint: baseTest.Hint, + Request: baseTest.Request, + Requests: baseTest.Requests, + Response: baseTest.Response, + Responses: baseTest.Responses, + } + multiRange := MultiRangeTestTransform(t, multiBase, byteRanges, fullData, contentType) + return test.SugarTests{singleRange, multiRange} +} diff --git a/tooling/test/sugar.go b/tooling/test/sugar.go index 81dc0bb63..57bafb385 100644 --- a/tooling/test/sugar.go +++ b/tooling/test/sugar.go @@ -1,8 +1,12 @@ package test import ( + "fmt" + "net/http" "net/url" + "testing" + "github.com/ipfs/gateway-conformance/tooling" "github.com/ipfs/gateway-conformance/tooling/check" "github.com/ipfs/gateway-conformance/tooling/tmpl" ) @@ -133,6 +137,11 @@ func (r RequestBuilder) Clone() RequestBuilder { } } +type ExpectValidator interface { + Validate(t *testing.T, res *http.Response, localReport Reporter) + Clone() ExpectValidator +} + type ExpectBuilder struct { StatusCode_ int `json:"statusCode,omitempty"` StatusCodeFrom_ int `json:"statusCodeFrom,omitempty"` @@ -142,6 +151,8 @@ type ExpectBuilder struct { Specs_ []string `json:"specs,omitempty"` } +var _ ExpectValidator = (*ExpectBuilder)(nil) + func Expect() ExpectBuilder { return ExpectBuilder{Body_: nil} } @@ -227,6 +238,150 @@ func (e ExpectBuilder) BodyWithHint(hint string, body interface{}) ExpectBuilder return e } +func (e ExpectBuilder) Validate(t *testing.T, res *http.Response, localReport Reporter) { + t.Helper() + tooling.LogSpecs(t, e.Specs_...) + + checks := validateResponse(t, e, res) + for _, c := range checks { + t.Run(c.testName, func(t *testing.T) { + tooling.LogSpecs(t, c.specs...) + if !c.checkOutput.Success { + localReport(t, c.checkOutput.Reason) + } + }) + } +} + +// Clone performs a deep clone of the ExpectBuilder +// Note: if there are [check.Check]s used in the inner header or body components those are only shallowly cloned +func (e ExpectBuilder) Clone() ExpectValidator { + clone := ExpectBuilder{} + var clonedHeaders []HeaderBuilder + for _, h := range e.Headers_ { + clonedHeaders = append(clonedHeaders, h.Clone()) + } + clone.StatusCode_ = e.StatusCode_ + clone.Headers_ = clonedHeaders + + if e.Body_ == nil { + return clone + } + + switch body := e.Body_.(type) { + case string: + clone.Body_ = body + case []byte: + clone.Body_ = body[:] + case check.CheckWithHint[string]: + clone.Body_ = body + case check.CheckWithHint[[]byte]: + clone.Body_ = body + case check.Check[string]: + clone.Body_ = body + case check.Check[[]byte]: + clone.Body_ = body + default: + panic("body must be string, []byte, or a regular check") + } + return clone +} + +type AllOfExpectBuilder struct { + Expect_ []ExpectValidator `json:"expect,omitempty"` +} + +var _ ExpectValidator = (*AllOfExpectBuilder)(nil) + +func AllOf(expect ...ExpectValidator) AllOfExpectBuilder { + return AllOfExpectBuilder{Expect_: expect} +} + +func (e AllOfExpectBuilder) Validate(t *testing.T, res *http.Response, localReport Reporter) { + t.Helper() + + for i, expect := range e.Expect_ { + t.Run( + fmt.Sprintf("Check %d", i), func(t *testing.T) { + expect.Validate(t, res, localReport) + }) + } +} + +// Clone performs a deep clone of the AllOfExpectBuilder +// Note: if there are [check.Check]s used in the header or body components of the nested builders those are only +// shallowly cloned +func (e AllOfExpectBuilder) Clone() ExpectValidator { + var clonedInnerValidators []ExpectValidator + for _, eb := range e.Expect_ { + clonedInnerValidators = append(clonedInnerValidators, eb.Clone()) + } + clone := AllOfExpectBuilder{Expect_: clonedInnerValidators} + return clone +} + +type AnyOfExpectBuilder struct { + Expect_ []ExpectBuilder `json:"expect,omitempty"` +} + +var _ ExpectValidator = (*AnyOfExpectBuilder)(nil) + +func AnyOf(expect ...ExpectBuilder) AnyOfExpectBuilder { + return AnyOfExpectBuilder{Expect_: expect} +} + +func (e AnyOfExpectBuilder) Validate(t *testing.T, res *http.Response, localReport Reporter) { + t.Helper() + + if len(e.Expect_) == 0 { + return + } + + hadASuccessfulResponse := false + for i, expect := range e.Expect_ { + checks := validateResponse(t, expect, res) + responseSucceeded := true + for _, c := range checks { + if !c.checkOutput.Success { + responseSucceeded = false + break + } + } + if responseSucceeded { + hadASuccessfulResponse = true + } + + t.Run(fmt.Sprintf("Check %d", i), + func(t *testing.T) { + if !responseSucceeded { + for _, c := range checks { + if c.checkOutput.Success { + t.Logf("Test %s passed", c.testName) + } else { + t.Logf("Test %s failed with: %s", c.testName, c.checkOutput.Reason) + } + } + } + }) + } + + if !hadASuccessfulResponse { + localReport(t, "none of the response options were valid") + } +} + +// Clone performs a deep clone of the AnyOfExpectBuilder +// Note: if there are [check.Check]s used in the header or body components of the nested builders those are only +// shallowly cloned +func (e AnyOfExpectBuilder) Clone() ExpectValidator { + var clonedInnerBuilders []ExpectBuilder + for _, eb := range e.Expect_ { + clonedInnerBuilders = append(clonedInnerBuilders, eb.Clone().(ExpectBuilder)) + } + clone := AnyOfExpectBuilder{Expect_: clonedInnerBuilders} + return clone +} + type HeaderBuilder struct { Key_ string `json:"key,omitempty"` Value_ string `json:"value,omitempty"` @@ -313,6 +468,19 @@ func (h HeaderBuilder) Exists() HeaderBuilder { return h.Not().IsEmpty() } +// Clone performs a shallow clone of the HeaderBuilder +// Note: The Check field is an interface and as a result is just copied +func (h HeaderBuilder) Clone() HeaderBuilder { + clone := HeaderBuilder{ + Key_: h.Key_, + Value_: h.Key_, + Check_: h.Check_, + Hint_: h.Hint_, + Not_: h.Not_, + } + return clone +} + type ExpectsBuilder struct { payloadsAreEquals bool } diff --git a/tooling/test/test.go b/tooling/test/test.go index 787f1a2eb..d1d072adf 100644 --- a/tooling/test/test.go +++ b/tooling/test/test.go @@ -19,12 +19,17 @@ type SugarTest struct { Specs []string Request RequestBuilder Requests []RequestBuilder - Response ExpectBuilder + Response ExpectValidator Responses ExpectsBuilder } type SugarTests []SugarTest +func (s SugarTests) Append(tests ...SugarTest) SugarTests { + s = append(s, tests...) + return s +} + func (s *SugarTest) AllSpecs() []string { if len(s.Specs) > 0 && s.Spec != "" { panic("cannot have both Spec and Specs") @@ -79,7 +84,9 @@ func run(t *testing.T, tests SugarTests) { for _, req := range test.Requests { _, res, localReport := runRequest(timeout, t, test, req) - validateResponse(t, test.Response, res, localReport) + if test.Response != nil { + test.Response.Validate(t, res, localReport) + } responses = append(responses, res) } @@ -89,7 +96,9 @@ func run(t *testing.T, tests SugarTests) { t.Run(name, func(t *testing.T) { tooling.LogSpecs(t, test.AllSpecs()...) _, res, localReport := runRequest(timeout, t, test, test.Request) - validateResponse(t, test.Response, res, localReport) + if test.Response != nil { + test.Response.Validate(t, res, localReport) + } }) } } diff --git a/tooling/test/validate.go b/tooling/test/validate.go index 7a71531bc..07ebcfef7 100644 --- a/tooling/test/validate.go +++ b/tooling/test/validate.go @@ -1,94 +1,104 @@ package test import ( + "bytes" "fmt" "io" "net/http" "testing" - "github.com/ipfs/gateway-conformance/tooling" "github.com/ipfs/gateway-conformance/tooling/check" ) +type testCheckOutput struct { + testName string + specs []string + checkOutput check.CheckOutput +} + func validateResponse( t *testing.T, expected ExpectBuilder, res *http.Response, - localReport Reporter, -) { +) []testCheckOutput { t.Helper() - tooling.LogSpecs(t, expected.Specs_...) + + var outputs []testCheckOutput if expected.StatusCode_ != 0 { - t.Run("Status code", func(t *testing.T) { - if res.StatusCode != expected.StatusCode_ { - localReport(t, "Status code is not %d. It is %d", expected.StatusCode_, res.StatusCode) - } - }) + output := testCheckOutput{testName: "Status code", checkOutput: check.CheckOutput{Success: true}} + if res.StatusCode != expected.StatusCode_ { + output.checkOutput.Success = false + output.checkOutput.Reason = fmt.Sprintf("Status code is not %d. It is %d", expected.StatusCode_, res.StatusCode) + } + outputs = append(outputs, output) } else if expected.StatusCodeFrom_ != 0 && expected.StatusCodeTo_ != 0 { - t.Run("Status code", func(t *testing.T) { - if res.StatusCode < expected.StatusCodeFrom_ || res.StatusCode > expected.StatusCodeTo_ { - localReport(t, "Status code is not between %d and %d. It is %d", expected.StatusCodeFrom_, expected.StatusCodeTo_, res.StatusCode) - } - }) + output := testCheckOutput{testName: "Status code", checkOutput: check.CheckOutput{Success: true}} + if res.StatusCode < expected.StatusCodeFrom_ || res.StatusCode > expected.StatusCodeTo_ { + output.checkOutput.Success = false + output.checkOutput.Reason = fmt.Sprintf("Status code is not between %d and %d. It is %d", expected.StatusCodeFrom_, expected.StatusCodeTo_, res.StatusCode) + } } for _, header := range expected.Headers_ { - t.Run(fmt.Sprintf("Header %s", header.Key_), func(t *testing.T) { - tooling.LogSpecs(t, header.Specs_...) - actual := res.Header.Values(header.Key_) + testName := fmt.Sprintf("Header %s", header.Key_) + actual := res.Header.Values(header.Key_) - c := header.Check_ - if header.Not_ { - c = check.Not(c) - } - output := c.Check(actual) + c := header.Check_ + if header.Not_ { + c = check.Not(c) + } + output := c.Check(actual) - if !output.Success { - if header.Hint_ == "" { - localReport(t, "Header '%s' %s", header.Key_, output.Reason) - } else { - localReport(t, "Header '%s' %s (%s)", header.Key_, output.Reason, header.Hint_) - } + if !output.Success { + if header.Hint_ == "" { + output.Reason = fmt.Sprintf("Header '%s' %s", header.Key_, output.Reason) + } else { + output.Reason = fmt.Sprintf("Header '%s' %s (%s)", header.Key_, output.Reason, header.Hint_) } - }) + } + + outputs = append(outputs, testCheckOutput{testName: testName, checkOutput: output, specs: header.Specs_}) } if expected.Body_ != nil { - t.Run("Body", func(t *testing.T) { - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - localReport(t, err) + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + outputs = append(outputs, testCheckOutput{testName: "Body", checkOutput: check.CheckOutput{Success: false, Reason: err.Error()}}) + return outputs + } + res.Body = io.NopCloser(bytes.NewBuffer(resBody)) + + var output check.CheckOutput + + switch v := expected.Body_.(type) { + case check.Check[string]: + output = v.Check(string(resBody)) + case check.Check[[]byte]: + output = v.Check(resBody) + case string: + output = check.IsEqual(v).Check(string(resBody)) + case []byte: + output = check.IsEqualBytes(v).Check(resBody) + default: + output = check.CheckOutput{ + Success: false, + Reason: fmt.Sprintf("Body check has an invalid type: %T", expected.Body_), } + } - var output check.CheckOutput - - switch v := expected.Body_.(type) { - case check.Check[string]: - output = v.Check(string(resBody)) - case check.Check[[]byte]: - output = v.Check(resBody) - case string: - output = check.IsEqual(v).Check(string(resBody)) - case []byte: - output = check.IsEqualBytes(v).Check(resBody) - default: - output = check.CheckOutput{ - Success: false, - Reason: fmt.Sprintf("Body check has an invalid type: %T", expected.Body_), - } + if !output.Success { + if output.Hint == "" { + output.Reason = fmt.Sprintf("Body %s", output.Reason) + } else { + output.Reason = fmt.Sprintf("Body %s (%s)", output.Reason, output.Hint) } + } - if !output.Success { - if output.Hint == "" { - localReport(t, "Body %s", output.Reason) - } else { - localReport(t, "Body %s (%s)", output.Reason, output.Hint) - } - } - }) + outputs = append(outputs, testCheckOutput{testName: "Body", checkOutput: output}) } + return outputs } func readPayload(res *http.Response) ([]byte, error) {