From cc68f06186a514d0b6284d8c094ff00cbc84c836 Mon Sep 17 00:00:00 2001 From: Laurent Senta Date: Tue, 30 May 2023 13:58:13 +0200 Subject: [PATCH] feat: add multi-test and complete t0123 --- fixtures/t0123/README.md | 66 ++++++++++ tests/t0123_gateway_json_cbor_test.go | 29 ++++- tooling/test/report.go | 5 +- tooling/test/run.go | 106 +++++++++++++++ tooling/test/sugar.go | 21 +++ tooling/test/test.go | 180 ++++---------------------- tooling/test/validate.go | 126 ++++++++++++++++++ 7 files changed, 371 insertions(+), 162 deletions(-) create mode 100644 fixtures/t0123/README.md create mode 100644 tooling/test/run.go create mode 100644 tooling/test/validate.go diff --git a/fixtures/t0123/README.md b/fixtures/t0123/README.md new file mode 100644 index 000000000..5a63b192a --- /dev/null +++ b/fixtures/t0123/README.md @@ -0,0 +1,66 @@ +# Dataset description/sources + +- dag-cbor-traversal.car + +- dag-json-traversal.car + +- dag-pb.car + +- dag-pb.json + +- fixtures.car + - raw CARv1 + +generated with: + +```sh +# using ipfs version 0.21.0-dev (03a98280e3e642774776cd3d0435ab53e5dfa867) + +mkdir -p rootDir/ipfs && +mkdir -p rootDir/ipns && +mkdir -p rootDir/api && +mkdir -p rootDir/ą/ę && +echo "{ \"test\": \"i am a plain json file\" }" > rootDir/ą/ę/t.json && +echo "I am a txt file on path with utf8" > rootDir/ą/ę/file-źł.txt && +echo "I am a txt file in confusing /api dir" > rootDir/api/file.txt && +echo "I am a txt file in confusing /ipfs dir" > rootDir/ipfs/file.txt && +echo "I am a txt file in confusing /ipns dir" > rootDir/ipns/file.txt && +DIR_CID=$(ipfs add -Qr --cid-version 1 rootDir) && +FILE_JSON_CID=$(ipfs files stat --enc=json /ipfs/$DIR_CID/ą/ę/t.json | jq -r .Hash) && +FILE_CID=$(ipfs files stat --enc=json /ipfs/$DIR_CID/ą/ę/file-źł.txt | jq -r .Hash) && +FILE_SIZE=$(ipfs files stat --enc=json /ipfs/$DIR_CID/ą/ę/file-źł.txt | jq -r .Size) +echo "$FILE_CID / $FILE_SIZE" + +echo DIR_CID=${DIR_CID} # ./rootDir +echo FILE_JSON_CID=${FILE_JSON_CID} # ./rootDir/ą/ę/t.json +echo FILE_CID=${FILE_CID} # ./rootDir/ą/ę/file-źł.txt +echo FILE_SIZE=${FILE_SIZE} + +ipfs dag export ${DIR_CID} > fixtures.car + +DAG_CBOR_TRAVERSAL_CID="bafyreibs4utpgbn7uqegmd2goqz4bkyflre2ek2iwv743fhvylwi4zeeim" +DAG_JSON_TRAVERSAL_CID="baguqeeram5ujjqrwheyaty3w5gdsmoz6vittchvhk723jjqxk7hakxkd47xq" +DAG_PB_CID="bafybeiegxwlgmoh2cny7qlolykdf7aq7g6dlommarldrbm7c4hbckhfcke" + +test_native_dag() { + NAME=$1 + CID=$2 + + IPNS_ID=$(ipfs key gen --ipns-base=base36 --type=ed25519 ${NAME}_test_key | head -n1 | tr -d "\n") + ipfs name publish --key ${NAME}_test_key --allow-offline --ttl=876600h --lifetime=876600h -Q "/ipfs/${CID}" > name_publish_out + + ipfs routing get /ipns/${IPNS_ID} > ${IPNS_ID}.ipns-record + + echo "IPNS_ID_${NAME}=${IPNS_ID}" +} + +test_native_dag "DAG_JSON" "$DAG_JSON_TRAVERSAL_CID" +test_native_dag "DAG_CBOR" "$DAG_CBOR_TRAVERSAL_CID" + +# DIR_CID=bafybeiafyvqlazbbbtjnn6how5d6h6l6rxbqc4qgpbmteaiskjrffmyy4a # ./rootDir +# FILE_JSON_CID=bafkreibrppizs3g7axs2jdlnjua6vgpmltv7k72l7v7sa6mmht6mne3qqe # ./rootDir/ą/ę/t.json +# FILE_CID=bafkreialihlqnf5uwo4byh4n3cmwlntwqzxxs2fg5vanqdi3d7tb2l5xkm # ./rootDir/ą/ę/file-źł.txt +# FILE_SIZE=34 +# IPNS_ID_DAG_JSON=k51qzi5uqu5dhjghbwdvbo6mi40htrq6e2z4pwgp15pgv3ho1azvidttzh8yy2 +# IPNS_ID_DAG_CBOR=k51qzi5uqu5dghjous0agrwavl8vzl64xckoqzwqeqwudfr74kfd11zcyk3b7l +``` diff --git a/tests/t0123_gateway_json_cbor_test.go b/tests/t0123_gateway_json_cbor_test.go index 272624b28..55fa96d87 100644 --- a/tests/t0123_gateway_json_cbor_test.go +++ b/tests/t0123_gateway_json_cbor_test.go @@ -838,7 +838,7 @@ func TestGatewayJSONCborAndIPNS(t *testing.T) { tests := SugarTests{} for _, row := range table { - plain := car.MustOpenUnixfsCar(Fmt("t0123/plain.{{format}}.car", row.Format)).MustGetRoot() + plain := car.MustOpenUnixfsCar(Fmt("t0123/dag-{{format}}-traversal.car", row.Format)).MustGetRoot() plainCID := plain.Cid() // # IPNS behavior (should be same as immutable /ipfs, but with different caching headers) @@ -849,14 +849,35 @@ func TestGatewayJSONCborAndIPNS(t *testing.T) { // curl -sX GET "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_ID" -o ipns_output && // test_cmp ipfs_output ipns_output // ' - // TODO: compare outputs. - + { + Name: Fmt("GET {{name}} from /ipns without explicit format returns the same payload as /ipfs", row.Name), + Requests: Requests( + Request(). + Path("ipfs/{{cid}}", plainCID), + Request(). + Path("ipns/{{id}}", row.fixture.Key()), + ), + Responses: Responses(). + HaveTheSamePayload(), + }, // test_expect_success "GET $name from /ipns without explicit format returns the same payload as /ipfs" ' // curl -sX GET "http://127.0.0.1:$GWAY_PORT/ipfs/$CID?format=dag-$format" -o ipfs_output && // curl -sX GET "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_ID?format=dag-$format" -o ipns_output && // test_cmp ipfs_output ipns_output // ' - // TODO: compare outputs. + { + Name: Fmt("GET {{name}} from /ipns with explicit format returns the same payload as /ipfs", row.Name), + Requests: Requests( + Request(). + Path("ipfs/{{cid}}", plainCID). + Query("format", "dag-{{format}}", row.Format), + Request(). + Path("ipns/{{id}}", row.fixture.Key()). + Query("format", "dag-{{format}}", row.Format), + ), + Responses: Responses(). + HaveTheSamePayload(), + }, // test_expect_success "GET $name from /ipns with explicit application/vnd.ipld.dag-$format has expected headers" ' // curl -svX GET -H "Accept: application/vnd.ipld.dag-$format" "http://127.0.0.1:$GWAY_PORT/ipns/$IPNS_ID" >/dev/null 2>curl_output && diff --git a/tooling/test/report.go b/tooling/test/report.go index 3371042e2..c9753a6fd 100644 --- a/tooling/test/report.go +++ b/tooling/test/report.go @@ -57,10 +57,13 @@ func report(t *testing.T, test SugarTest, req *http.Request, res *http.Response, var err error switch v := v.(type) { case *http.Request: + if v == nil { + return "nil" // golang does not catch the nil case above + } b, err = httputil.DumpRequestOut(v, true) case *http.Response: if v == nil { - return "nil" + return "nil" // golang does not catch the nil case above } // TODO: we have to disable the body dump because // it triggers an error: diff --git a/tooling/test/run.go b/tooling/test/run.go new file mode 100644 index 000000000..c64bf2a60 --- /dev/null +++ b/tooling/test/run.go @@ -0,0 +1,106 @@ +package test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "testing" +) + +type Reporter func(t *testing.T, msg interface{}, rest ...interface{}) + +func runRequest(ctx context.Context, t *testing.T, test SugarTest, builder RequestBuilder) (*http.Request, *http.Response, Reporter) { + method := builder.Method_ + if method == "" { + method = "GET" + } + + // Prepare a client, + // use proxy, deal with redirects, etc. + client := &http.Client{} + if builder.UseProxyTunnel_ { + if builder.Proxy_ == "" { + t.Fatal("ProxyTunnel requires a proxy") + } + + client = NewProxyTunnelClient(builder.Proxy_) + } else if builder.Proxy_ != "" { + client = NewProxyClient(builder.Proxy_) + } + + if builder.DoNotFollowRedirects_ { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + + var res *http.Response = nil + var req *http.Request = nil + + var localReport Reporter = func(t *testing.T, msg interface{}, rest ...interface{}) { + var err error + switch msg := msg.(type) { + case string: + err = fmt.Errorf(msg, rest...) + case error: + err = msg + default: + panic("msg must be string or error") + } + + report(t, test, req, res, err) + } + + var url string + if builder.URL_ != "" && builder.Path_ != "" { + localReport(t, "Both 'URL' and 'Path' are set") + } + if builder.URL_ == "" && builder.Path_ == "" { + localReport(t, "Neither 'URL' nor 'Path' are set") + } + if builder.URL_ != "" { + url = builder.URL_ + } + if builder.Path_ != "" { + url = fmt.Sprintf("%s/%s", GatewayURL, builder.Path_) + } + + query := builder.Query_.Encode() + if query != "" { + url = fmt.Sprintf("%s?%s", url, query) + } + + var body io.Reader + if builder.Body_ != nil { + body = bytes.NewBuffer(builder.Body_) + } + + // create a request + req, err := http.NewRequest(method, url, body) + if err != nil { + t.Fatal(err) + } + + // add headers + for key, value := range builder.Headers_ { + req.Header.Add(key, value) + + // https://github.com/golang/go/issues/7682 + if key == "Host" { + req.Host = value + } + } + + // send request + log.Debugf("Querying %s", url) + req = req.WithContext(ctx) + + res, err = client.Do(req) + if err != nil { + localReport(t, "Querying %s failed: %s", url, err) + } + + return req, res, localReport +} diff --git a/tooling/test/sugar.go b/tooling/test/sugar.go index b870f498e..763874810 100644 --- a/tooling/test/sugar.go +++ b/tooling/test/sugar.go @@ -24,6 +24,10 @@ func Request() RequestBuilder { Query_: make(url.Values)} } +func Requests(rs ...RequestBuilder) []RequestBuilder { + return rs +} + func (r RequestBuilder) Path(path string, args ...any) RequestBuilder { r.Path_ = tmpl.Fmt(path, args...) return r @@ -98,6 +102,10 @@ func Expect() ExpectBuilder { return ExpectBuilder{Body_: nil} } +func ResponsesAreEqual() ExpectBuilder { + return ExpectBuilder{Body_: check.IsEqual} +} + func (e ExpectBuilder) Status(statusCode int) ExpectBuilder { e.StatusCode_ = statusCode return e @@ -226,3 +234,16 @@ func (h HeaderBuilder) Not() HeaderBuilder { func (h HeaderBuilder) Exists() HeaderBuilder { return h.Not().IsEmpty() } + +type ExpectsBuilder struct { + payloadsAreEquals bool +} + +func Responses() ExpectsBuilder { + return ExpectsBuilder{} +} + +func (e ExpectsBuilder) HaveTheSamePayload() ExpectsBuilder { + e.payloadsAreEquals = true + return e +} diff --git a/tooling/test/test.go b/tooling/test/test.go index e58900066..9f8403fef 100644 --- a/tooling/test/test.go +++ b/tooling/test/test.go @@ -1,23 +1,21 @@ package test import ( - "bytes" "context" - "fmt" - "io" "net/http" "testing" "time" - "github.com/ipfs/gateway-conformance/tooling/check" "github.com/ipfs/gateway-conformance/tooling/specs" ) type SugarTest struct { - Name string - Hint string - Request RequestBuilder - Response ExpectBuilder + Name string + Hint string + Request RequestBuilder + Requests []RequestBuilder + Response ExpectBuilder + Responses ExpectsBuilder } type SugarTests []SugarTest @@ -44,158 +42,26 @@ func RunIfSpecsAreEnabled( func Run(t *testing.T, tests SugarTests) { for _, test := range tests { - t.Run(test.Name, func(t *testing.T) { - method := test.Request.Method_ - if method == "" { - method = "GET" - } + timeout, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() - // Prepare a client, - // use proxy, deal with redirects, etc. - client := &http.Client{} - if test.Request.UseProxyTunnel_ { - if test.Request.Proxy_ == "" { - t.Fatal("ProxyTunnel requires a proxy") - } - - client = NewProxyTunnelClient(test.Request.Proxy_) - } else if test.Request.Proxy_ != "" { - client = NewProxyClient(test.Request.Proxy_) - } - - if test.Request.DoNotFollowRedirects_ { - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - } - - var res *http.Response = nil - var req *http.Request = nil - - localReport := func(t *testing.T, msg interface{}, rest ...interface{}) { - var err error - switch msg := msg.(type) { - case string: - err = fmt.Errorf(msg, rest...) - case error: - err = msg - default: - panic("msg must be string or error") - } + if len(test.Requests) > 0 { + t.Run(test.Name, func(t *testing.T) { + responses := make([]*http.Response, 0, len(test.Requests)) - report(t, test, req, res, err) - } - - var url string - if test.Request.URL_ != "" && test.Request.Path_ != "" { - localReport(t, "Both 'URL' and 'Path' are set") - } - if test.Request.URL_ == "" && test.Request.Path_ == "" { - localReport(t, "Neither 'URL' nor 'Path' are set") - } - if test.Request.URL_ != "" { - url = test.Request.URL_ - } - if test.Request.Path_ != "" { - url = fmt.Sprintf("%s/%s", GatewayURL, test.Request.Path_) - } - - query := test.Request.Query_.Encode() - if query != "" { - url = fmt.Sprintf("%s?%s", url, query) - } - - var body io.Reader - if test.Request.Body_ != nil { - body = bytes.NewBuffer(test.Request.Body_) - } - - // create a request - req, err := http.NewRequest(method, url, body) - if err != nil { - t.Fatal(err) - } - - // add headers - for key, value := range test.Request.Headers_ { - req.Header.Add(key, value) - - // https://github.com/golang/go/issues/7682 - if key == "Host" { - req.Host = value + for _, req := range test.Requests { + _, res, localReport := runRequest(timeout, t, test, req) + validateResponse(t, test.Response, res, localReport) + responses = append(responses, res) } - } - - // send request - log.Debugf("Querying %s", url) - timeout, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - req = req.WithContext(timeout) - res, err = client.Do(req) - if err != nil { - localReport(t, "Querying %s failed: %s", url, err) - } - - if test.Response.StatusCode_ != 0 { - if res.StatusCode != test.Response.StatusCode_ { - localReport(t, "Status code is not %d. It is %d", test.Response.StatusCode_, res.StatusCode) - } - } - - for _, header := range test.Response.Headers_ { - t.Run(fmt.Sprintf("Header %s", header.Key_), func(t *testing.T) { - actual := res.Header.Values(header.Key_) - - 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 test.Response.Body_ != nil { - defer res.Body.Close() - resBody, err := io.ReadAll(res.Body) - if err != nil { - localReport(t, err) - } - - var output check.CheckOutput - - switch v := test.Response.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", test.Response.Body_), - } - } - - if !output.Success { - if output.Hint == "" { - localReport(t, "Body %s", output.Reason) - } else { - localReport(t, "Body %s (%s)", output.Reason, output.Hint) - } - } - } - }) + validateResponses(t, test.Responses, responses) + }) + } else { + t.Run(test.Name, func(t *testing.T) { + _, res, localReport := runRequest(timeout, t, test, test.Request) + validateResponse(t, test.Response, res, localReport) + }) + } } } diff --git a/tooling/test/validate.go b/tooling/test/validate.go new file mode 100644 index 000000000..60d8b74c1 --- /dev/null +++ b/tooling/test/validate.go @@ -0,0 +1,126 @@ +package test + +import ( + "fmt" + "io" + "net/http" + "testing" + + "github.com/ipfs/gateway-conformance/tooling/check" +) + +func validateResponse( + t *testing.T, + expected ExpectBuilder, + res *http.Response, + localReport Reporter, +) { + if expected.StatusCode_ != 0 { + if res.StatusCode != expected.StatusCode_ { + localReport(t, "Status code is not %d. It is %d", expected.StatusCode_, res.StatusCode) + } + } + + for _, header := range expected.Headers_ { + t.Run(fmt.Sprintf("Header %s", header.Key_), func(t *testing.T) { + actual := res.Header.Values(header.Key_) + + 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 expected.Body_ != nil { + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + if err != nil { + localReport(t, err) + } + + 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 == "" { + localReport(t, "Body %s", output.Reason) + } else { + localReport(t, "Body %s (%s)", output.Reason, output.Hint) + } + } + } +} + +func readPayload(res *http.Response) ([]byte, error) { + defer res.Body.Close() + return io.ReadAll(res.Body) +} + +func validateResponses( + t *testing.T, + expected ExpectsBuilder, + responses []*http.Response, +) { + if expected.payloadsAreEquals { + dumps := make([][]byte, 0, len(responses)) + + for _, res := range responses { + if res == nil { + dumps = append(dumps, []byte("")) + } else { + // TODO: there is a usecase for mixing "one expect" (validate a single response) + // and "expectS" (validates multiple responses). This will fail here if we check the body. + // Support this usecase once this becomes a request feature. + payload, err := readPayload(res) + if err != nil { + t.Errorf("Failed to read payload: %s", err) + } + dumps = append(dumps, payload) + } + } + + if len(dumps) > 1 { + for i := 1; i < len(dumps); i++ { + // if the payloads are not equal, we show an error + if string(dumps[i]) != string(dumps[0]) { + t.Errorf(` +Responses are not equal +==== Request %d ==== + +%s + +==== Request %d ==== + +%s + +`, 0+1, string(dumps[0]), i+1, string(dumps[i])) + } + } + } + } +}