From d6a4caaf2a1a17b9ed331f7a17356d8ba0ff0352 Mon Sep 17 00:00:00 2001 From: Adin Schmahmann Date: Tue, 5 Sep 2023 20:00:50 -0400 Subject: [PATCH] feat: add AllOf response type and range request helpers switch tests to use range request helpers --- tests/path_gateway_dag_test.go | 210 ++++++++++++++++++++++++++++ tests/trustless_gateway_raw_test.go | 120 +++------------- tooling/helpers/range.go | 176 +++++++++++++++++++++++ tooling/test/sugar.go | 22 +++ tooling/test/validate.go | 2 + 5 files changed, 431 insertions(+), 99 deletions(-) create mode 100644 tooling/helpers/range.go diff --git a/tests/path_gateway_dag_test.go b/tests/path_gateway_dag_test.go index 31fa64b12..8a0e36362 100644 --- a/tests/path_gateway_dag_test.go +++ b/tests/path_gateway_dag_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -300,6 +301,57 @@ func TestPlainCodec(t *testing.T) { row.Checker(formatted), ), }, + { + Name: Fmt("GET {{name}} on /ipfs with no explicit header and range returns range", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", plainOrDagCID). + Headers( + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Status(206). + Headers( + Header("Content-Type", "application/vnd.ipld.dag-{{format}}", row.Format), + Header("Content-Range", "bytes 6-16/57"), + ). + Body(row.Checker(plainOrDag.RawData()[6:17])), + }, + { + Name: Fmt("GET {{name}} on /ipfs with non-dag content headers and range returns range", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", plainOrDagCID). + Headers( + Header("Accept", "application/{{format}}", row.Format), + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Status(206). + Headers( + Header("Content-Type", "application/{{format}}", row.Format), + Header("Content-Range", "bytes 6-16/57"), + ). + Body(row.Checker(plainOrDag.RawData()[6:17])), + }, + { + Name: Fmt("GET {{name}} on /ipfs with dag content headers and range returns range", row.Name), + 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). + Headers( + Header("Accept", "application/vnd.ipld.dag-{{format}}", row.Format), + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Status(206). + Headers( + Header("Content-Type", "application/vnd.ipld.dag-{{format}}", row.Format), + Header("Content-Range", "bytes 6-16/57"), + ). + Body(row.Checker(plainOrDag.RawData()[6:17])), + }, } RunWithSpecs(t, tests, specs.PathGatewayDAG) @@ -563,10 +615,168 @@ func TestNativeDag(t *testing.T) { Contains(""), ), }, + helpers.SingleRangeTestTransform(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with no explicit header and single range", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID). + Headers( + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Headers( + Header("Content-Type", "application/vnd.ipld.dag-{{format}}", row.Format), + ), + }, + helpers.SimpleByteRange(6, 16, dagTraversal.RawData()[6:17]), + dagTraversal.RawData(), + ), + helpers.SingleRangeTestTransform(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with dag content headers and single range", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-{{format}}", row.Format), + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Headers( + Header("Content-Type", "application/vnd.ipld.dag-{{format}}", row.Format), + ), + }, + helpers.SimpleByteRange(6, 16, dagTraversal.RawData()[6:17]), + dagTraversal.RawData(), + ), + helpers.SingleRangeTestTransform(t, + SugarTest{ + Name: Fmt("GET {{name}} on /ipfs with non-dag content headers and single range", row.Name), + Request: Request(). + Path("/ipfs/{{cid}}/", dagTraversalCID). + Headers( + Header("Accept", "application/{{format}}", row.Format), + Header("Range", "bytes=6-16"), + ), + Response: Expect(). + Headers( + Header("Content-Type", "application/{{format}}", row.Format), + ), + }, + helpers.SimpleByteRange(6, 16, dagTraversal.RawData()[6:17]), + dagTraversal.RawData(), + ), } 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 := SugarTests{ + helpers.SingleRangeTestTransform(t, SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to application/vnd.ipld.dag-json with single range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-json"), + Header("Range", "bytes=1-5"), + ), + Response: Expect(), + }, helpers.SimpleByteRange(1, 5, dagJsonConvertedData[1:6]), dagJsonConvertedData), + helpers.MultiRangeTestTransform(t, SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to application/vnd.ipld.dag-json with multiple range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "application/vnd.ipld.dag-json"), + Header("Range", "bytes=1-5,93-104"), + ), + Response: Expect(), + }, helpers.ByteRanges{ + helpers.SimpleByteRange(1, 5, dagJsonConvertedData[1:6]), + helpers.SimpleByteRange(93, 104, dagJsonConvertedData[93:105])}, + dagJsonConvertedData), + } + + 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 := SugarTests{ + helpers.SingleRangeTestTransform(t, SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to text/html with single range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "text/html"), + Header("Range", "bytes=1-5"), + ), + Response: Expect(), + }, helpers.SimpleByteRange(1, 5, dagCborHTMLRendering[1:6]), dagCborHTMLRendering), + helpers.MultiRangeTestTransform(t, SugarTest{ + Name: "Convert application/vnd.ipld.dag-cbor to text/html with multiple range request includes correct bytes", + Hint: "", + Request: Request(). + Path("/ipfs/{{cid}}/", dagCborCID). + Headers( + Header("Accept", "text/html"), + Header("Range", "bytes=1-5,93-104"), + ), + Response: Expect(), + }, helpers.ByteRanges{ + helpers.SimpleByteRange(1, 5, dagCborHTMLRendering[1:6]), + helpers.SimpleByteRange(93, 104, dagCborHTMLRendering[93:105])}, + dagCborHTMLRendering), + } + + 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 8094f92d5..308ece9d1 100644 --- a/tests/trustless_gateway_raw_test.go +++ b/tests/trustless_gateway_raw_test.go @@ -1,12 +1,12 @@ package tests import ( + "github.com/ipfs/gateway-conformance/tooling/helpers" "strconv" "strings" "testing" "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" ) @@ -133,14 +133,9 @@ func TestTrustlessRawRanges(t *testing.T) { // correctly. fixture := car.MustOpenUnixfsCar("gateway-raw-block.car") - var ( - contentType string - contentRange string - ) - - RunWithSpecs(t, SugarTests{ - { - Name: "GETaa with application/vnd.ipld.raw with single range request includes correct bytes", + singleRangeTest := helpers.SingleRangeTestTransform(t, + SugarTest{ + 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( @@ -148,86 +143,16 @@ func TestTrustlessRawRanges(t *testing.T) { 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", - 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 - }), ), }, - }, 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. + helpers.SimpleByteRange(6, 16, fixture.MustGetRawData("dir", "ascii.txt")[6:17]), + fixture.MustGetRawData("dir", "ascii.txt"), + ) - 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{ + multiRangeTest := helpers.MultiRangeTestTransform(t, + 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")). @@ -235,21 +160,18 @@ func TestTrustlessRawRanges(t *testing.T) { 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") + Response: Expect(), + }, + helpers.ByteRanges{ + helpers.SimpleByteRange(6, 16, fixture.MustGetRawData("dir", "ascii.txt")[6:17]), + helpers.SimpleByteRange(0, 4, fixture.MustGetRawData("dir", "ascii.txt")[0:5]), + }, + fixture.MustGetRawData("dir", "ascii.txt"), + ) + + tests := SugarTests{ + singleRangeTest, + multiRangeTest, } RunWithSpecs(t, tests, specs.TrustlessGatewayRaw) diff --git a/tooling/helpers/range.go b/tooling/helpers/range.go new file mode 100644 index 000000000..75c4dfe21 --- /dev/null +++ b/tooling/helpers/range.go @@ -0,0 +1,176 @@ +package helpers + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/ipfs/gateway-conformance/tooling/check" + "github.com/ipfs/gateway-conformance/tooling/test" +) + +// ByteRange describes an HTTP range request and the data it corresponds to. "From" and "To" mostly +// follow [HTTP Byte Range] Request semantics: +// +// - From >= 0 and To = nil: Get the file (From, Length) +// - From >= 0 and To >= 0: Get the range (From, To) +// - From >= 0 and To <0: Get the range (From, Length - To) +// +// [HTTP Byte Range]: https://httpwg.org/specs/rfc9110.html#rfc.section.14.1.2 +type ByteRange struct { + From uint64 + To *int64 + RangeBytes []byte +} + +func SimpleByteRange(from, to uint64, data []byte) ByteRange { + toInt := int64(to) + return ByteRange{ + From: from, + To: &toInt, + RangeBytes: data, + } +} + +func (b ByteRange) GetRangeString(t *testing.T) string { + strWithoutPrefix := b.getRangeStringWithoutPrefix(t) + return fmt.Sprintf("bytes=%s", strWithoutPrefix) +} + +func (b ByteRange) getRangeStringWithoutPrefix(t *testing.T) string { + if b.To == nil { + return fmt.Sprintf("%d-", b.From) + } + + to := *b.To + if to >= 0 { + return fmt.Sprintf("%d-%d", b.From, to) + } + + if to < 0 && b.From != 0 { + t.Fatalf("for a suffix request the From field must be 0") + } + return fmt.Sprintf("%d", to) +} + +func (b ByteRange) getRange(t *testing.T, totalSize int64) (uint64, uint64) { + if totalSize < 0 { + t.Fatalf("total size must be greater than 0") + } + + if b.To == nil { + return b.From, uint64(totalSize) + } + + to := *b.To + if to >= 0 { + return b.From, uint64(to) + } + + if to < 0 && b.From != 0 { + t.Fatalf("for a suffix request the From field must be 0") + } + + start := int64(totalSize) + to + if start < 0 { + t.Fatalf("suffix request must not start before the start of the file") + } + + return uint64(start), uint64(totalSize) +} + +type ByteRanges []ByteRange + +func (b ByteRanges) GetRangeString(t *testing.T) string { + var rangeStrs []string + for _, r := range b { + rangeStrs = append(rangeStrs, r.getRangeStringWithoutPrefix(t)) + } + return fmt.Sprintf("bytes=%s", strings.Join(rangeStrs, ",")) +} + +// 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, brange ByteRange, fullData []byte) test.SugarTest { + modifiedRequest := baseTest.Request.Clone().Header("Range", brange.GetRangeString(t)) + if baseTest.Requests != nil { + t.Fatal("does not support multiple requests or responses") + } + + fullSize := int64(len(fullData)) + start, end := brange.getRange(t, fullSize) + + rangeTest := test.SugarTest{ + Name: baseTest.Name, + Hint: baseTest.Hint, + Request: modifiedRequest, + Requests: nil, + Response: test.AllOf( + baseTest.Response, + test.AnyOf( + test.Expect().Status(http.StatusPartialContent).Body(brange.RangeBytes).Headers( + test.Header("Content-Range").Equals("bytes {{start}}-{{end}}/{{length}}", start, end, fullSize), + ), + test.Expect().Status(http.StatusOK).Body(fullData), + ), + ), + Responses: baseTest.Responses, + } + + 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 headers and verifies whether a valid response is given for the requested ranges. +// +// 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, testWithoutRangeRequestHeader test.SugarTest, branges ByteRanges, fullData []byte) test.SugarTest { + modifiedRequest := testWithoutRangeRequestHeader.Request.Clone().Header("Range", branges.GetRangeString(t)) + if testWithoutRangeRequestHeader.Requests != nil { + t.Fatal("does not support multiple requests or responses") + } + + fullSize := int64(len(fullData)) + type rng struct { + start, end uint64 + } + + var multirangeBodyChecks []check.Check[string] + var ranges []rng + for _, r := range branges { + start, end := r.getRange(t, fullSize) + 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(r.RangeBytes)), + ) + } + + rangeTest := test.SugarTest{ + Name: testWithoutRangeRequestHeader.Name, + Hint: testWithoutRangeRequestHeader.Hint, + Request: modifiedRequest, + Requests: nil, + Response: test.AllOf( + testWithoutRangeRequestHeader.Response, + test.AnyOf( + test.Expect().Status(http.StatusOK).Body(fullData), + test.Expect().Status(http.StatusPartialContent).Body(branges[0].RangeBytes).Headers( + test.Header("Content-Range").Equals("bytes {{start}}-{{end}}/{{length}}", ranges[0].start, ranges[0].end, fullSize), + ), + test.Expect().Status(http.StatusPartialContent).Body( + check.And( + append([]check.Check[string]{check.Contains("Content-Type: application/vnd.ipld.raw")}, multirangeBodyChecks...)..., + ), + ).Headers(test.Header("Content-Type").Contains("multipart/byteranges")), + ), + ), + Responses: testWithoutRangeRequestHeader.Responses, + } + + return rangeTest +} diff --git a/tooling/test/sugar.go b/tooling/test/sugar.go index 93030adfb..888cb3334 100644 --- a/tooling/test/sugar.go +++ b/tooling/test/sugar.go @@ -145,6 +145,8 @@ type ExpectBuilder struct { Body_ interface{} `json:"body,omitempty"` } +var _ ExpectValidator = (*ExpectBuilder)(nil) + func Expect() ExpectBuilder { return ExpectBuilder{Body_: nil} } @@ -227,10 +229,30 @@ func (e ExpectBuilder) Validate(t *testing.T, res *http.Response, localReport Re } } +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 _, expect := range e.Expect_ { + expect.Validate(t, res, localReport) + } +} + type AnyOfExpectBuilder struct { Expect_ []ExpectBuilder `json:"expect,omitempty"` } +var _ ExpectValidator = (*AnyOfExpectBuilder)(nil) + func AnyOf(expect ...ExpectBuilder) AnyOfExpectBuilder { return AnyOfExpectBuilder{Expect_: expect} } diff --git a/tooling/test/validate.go b/tooling/test/validate.go index 16f97ca2b..ffb62b204 100644 --- a/tooling/test/validate.go +++ b/tooling/test/validate.go @@ -1,6 +1,7 @@ package test import ( + "bytes" "fmt" "io" "net/http" @@ -64,6 +65,7 @@ func validateResponse( 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