package common import ( "context" "fmt" "io" "math/rand" "net/http" "net/http/httputil" "os" "path/filepath" "testing" "time" "github.com/ipfs-cluster/ipfs-cluster/api" "github.com/ipfs-cluster/ipfs-cluster/api/common/test" rpctest "github.com/ipfs-cluster/ipfs-cluster/test" libp2p "github.com/libp2p/go-libp2p" rpc "github.com/libp2p/go-libp2p-gorpc" ma "github.com/multiformats/go-multiaddr" ) const ( SSLCertFile = "test/server.crt" SSLKeyFile = "test/server.key" validUserName = "validUserName" validUserPassword = "validUserPassword" adminUserName = "adminUserName" adminUserPassword = "adminUserPassword" invalidUserName = "invalidUserName" invalidUserPassword = "invalidUserPassword" ) var ( validToken, _ = generateSignedTokenString(validUserName, validUserPassword) invalidToken, _ = generateSignedTokenString(invalidUserName, invalidUserPassword) ) func routes(c *rpc.Client) []Route { return []Route{ { "Test", "GET", "/test", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "application/json") w.Write([]byte(`{ "thisis": "atest" }`)) }, }, } } func testAPIwithConfig(t *testing.T, cfg *Config, name string) *API { ctx := context.Background() apiMAddr, _ := ma.NewMultiaddr("/ip4/127.0.0.1/tcp/0") h, err := libp2p.New(libp2p.ListenAddrs(apiMAddr)) if err != nil { t.Fatal(err) } cfg.HTTPListenAddr = []ma.Multiaddr{apiMAddr} rest, err := NewAPIWithHost(ctx, cfg, h, routes) if err != nil { t.Fatalf("should be able to create a new %s API: %s", name, err) } // No keep alive for tests rest.server.SetKeepAlivesEnabled(false) rest.SetClient(rpctest.NewMockRPCClient(t)) return rest } func testAPI(t *testing.T) *API { cfg := newDefaultTestConfig(t) cfg.CORSAllowedOrigins = []string{test.ClientOrigin} cfg.CORSAllowedMethods = []string{"GET", "POST", "DELETE"} //cfg.CORSAllowedHeaders = []string{"Content-Type"} cfg.CORSMaxAge = 10 * time.Minute return testAPIwithConfig(t, cfg, "basic") } func testHTTPSAPI(t *testing.T) *API { cfg := newDefaultTestConfig(t) cfg.PathSSLCertFile = SSLCertFile cfg.PathSSLKeyFile = SSLKeyFile var err error cfg.TLS, err = newTLSConfig(cfg.PathSSLCertFile, cfg.PathSSLKeyFile) if err != nil { t.Fatal(err) } return testAPIwithConfig(t, cfg, "https") } func testAPIwithBasicAuth(t *testing.T) *API { cfg := newDefaultTestConfig(t) cfg.BasicAuthCredentials = map[string]string{ validUserName: validUserPassword, adminUserName: adminUserPassword, } return testAPIwithConfig(t, cfg, "Basic Authentication") } func TestAPIShutdown(t *testing.T) { ctx := context.Background() rest := testAPI(t) err := rest.Shutdown(ctx) if err != nil { t.Error("should shutdown cleanly: ", err) } // test shutting down twice rest.Shutdown(ctx) } func TestHTTPSTestEndpoint(t *testing.T) { ctx := context.Background() rest := testAPI(t) httpsrest := testHTTPSAPI(t) defer rest.Shutdown(ctx) defer httpsrest.Shutdown(ctx) tf := func(t *testing.T, url test.URLFunc) { r := make(map[string]string) test.MakeGet(t, rest, url(rest)+"/test", &r) if r["thisis"] != "atest" { t.Error("expected correct body") } } httpstf := func(t *testing.T, url test.URLFunc) { r := make(map[string]string) test.MakeGet(t, httpsrest, url(httpsrest)+"/test", &r) if r["thisis"] != "atest" { t.Error("expected correct body") } } test.BothEndpoints(t, tf) test.HTTPSEndPoint(t, httpstf) } func TestAPILogging(t *testing.T) { ctx := context.Background() cfg := newDefaultTestConfig(t) logFile, err := filepath.Abs("http.log") if err != nil { t.Fatal(err) } cfg.HTTPLogFile = logFile rest := testAPIwithConfig(t, cfg, "log_enabled") defer os.Remove(cfg.HTTPLogFile) info, err := os.Stat(cfg.HTTPLogFile) if err != nil { t.Fatal(err) } if info.Size() > 0 { t.Errorf("expected empty log file") } id := api.ID{} test.MakeGet(t, rest, test.HTTPURL(rest)+"/test", &id) info, err = os.Stat(cfg.HTTPLogFile) if err != nil { t.Fatal(err) } size1 := info.Size() if size1 == 0 { t.Error("did not expect an empty log file") } // Restart API and make sure that logs are being appended rest.Shutdown(ctx) rest = testAPIwithConfig(t, cfg, "log_enabled") defer rest.Shutdown(ctx) test.MakeGet(t, rest, test.HTTPURL(rest)+"/id", &id) info, err = os.Stat(cfg.HTTPLogFile) if err != nil { t.Fatal(err) } size2 := info.Size() if size2 == 0 { t.Error("did not expect an empty log file") } if !(size2 > size1) { t.Error("logs were not appended") } } func TestNotFoundHandler(t *testing.T) { ctx := context.Background() rest := testAPI(t) defer rest.Shutdown(ctx) tf := func(t *testing.T, url test.URLFunc) { bytes := make([]byte, 10) for i := 0; i < 10; i++ { bytes[i] = byte(65 + rand.Intn(25)) //A=65 and Z = 65+25 } var errResp api.Error test.MakePost(t, rest, url(rest)+"/"+string(bytes), []byte{}, &errResp) if errResp.Code != 404 { t.Errorf("expected error not found: %+v", errResp) } var errResp1 api.Error test.MakeGet(t, rest, url(rest)+"/"+string(bytes), &errResp1) if errResp1.Code != 404 { t.Errorf("expected error not found: %+v", errResp) } } test.BothEndpoints(t, tf) } func TestCORS(t *testing.T) { ctx := context.Background() rest := testAPI(t) defer rest.Shutdown(ctx) type testcase struct { method string path string } tf := func(t *testing.T, url test.URLFunc) { reqHeaders := make(http.Header) reqHeaders.Set("Origin", "myorigin") reqHeaders.Set("Access-Control-Request-Headers", "Content-Type") for _, tc := range []testcase{ {"GET", "/test"}, // testcase{}, } { reqHeaders.Set("Access-Control-Request-Method", tc.method) headers := test.MakeOptions(t, rest, url(rest)+tc.path, reqHeaders) aorigin := headers.Get("Access-Control-Allow-Origin") amethods := headers.Get("Access-Control-Allow-Methods") aheaders := headers.Get("Access-Control-Allow-Headers") acreds := headers.Get("Access-Control-Allow-Credentials") maxage := headers.Get("Access-Control-Max-Age") if aorigin != "myorigin" { t.Error("Bad ACA-Origin:", aorigin) } if amethods != tc.method { t.Error("Bad ACA-Methods:", amethods) } if aheaders != "Content-Type" { t.Error("Bad ACA-Headers:", aheaders) } if acreds != "true" { t.Error("Bad ACA-Credentials:", acreds) } if maxage != "600" { t.Error("Bad AC-Max-Age:", maxage) } } } test.BothEndpoints(t, tf) } type responseChecker func(*http.Response) error type requestShaper func(*http.Request) error type httpTestcase struct { method string path string header http.Header body io.ReadCloser shaper requestShaper checker responseChecker } func httpStatusCodeChecker(resp *http.Response, expectedStatus int) error { if resp.StatusCode == expectedStatus { return nil } return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) } func assertHTTPStatusIsUnauthoriazed(resp *http.Response) error { return httpStatusCodeChecker(resp, http.StatusUnauthorized) } func assertHTTPStatusIsTooLarge(resp *http.Response) error { return httpStatusCodeChecker(resp, http.StatusRequestHeaderFieldsTooLarge) } func makeHTTPStatusNegatedAssert(checker responseChecker) responseChecker { return func(resp *http.Response) error { if checker(resp) == nil { return fmt.Errorf("unexpected HTTP status code: %d", resp.StatusCode) } return nil } } func (tc *httpTestcase) getTestFunction(api *API) test.Func { return func(t *testing.T, prefixMaker test.URLFunc) { h := test.MakeHost(t, api) defer h.Close() url := prefixMaker(api) + tc.path c := test.HTTPClient(t, h, test.IsHTTPS(url)) req, err := http.NewRequest(tc.method, url, tc.body) if err != nil { t.Fatal("Failed to assemble a HTTP request: ", err) } if tc.header != nil { req.Header = tc.header } if tc.shaper != nil { err := tc.shaper(req) if err != nil { t.Fatal("Failed to shape a HTTP request: ", err) } } resp, err := c.Do(req) if err != nil { t.Fatal("Failed to make a HTTP request: ", err) } if tc.checker != nil { if err := tc.checker(resp); err != nil { r, e := httputil.DumpRequest(req, true) if e != nil { t.Errorf("Assertion failed with: %q", err) } else { t.Errorf("Assertion failed with: %q on request: \n%.100s", err, r) } } } } } func makeBasicAuthRequestShaper(username, password string) requestShaper { return func(req *http.Request) error { req.SetBasicAuth(username, password) return nil } } func makeTokenAuthRequestShaper(token string) requestShaper { return func(req *http.Request) error { req.Header.Set("Authorization", "Bearer "+token) return nil } } func makeLongHeaderShaper(size int) requestShaper { return func(req *http.Request) error { for sz := size; sz > 0; sz -= 8 { req.Header.Add("Foo", "bar") } return nil } } func TestBasicAuth(t *testing.T) { ctx := context.Background() rest := testAPIwithBasicAuth(t) defer rest.Shutdown(ctx) for _, tc := range []httpTestcase{ {}, { method: "", path: "", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "POST", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "DELETE", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "HEAD", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "OPTIONS", // Always allowed for CORS path: "/foo", checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "PUT", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "TRACE", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "CONNECT", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "BAR", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeBasicAuthRequestShaper(invalidUserName, invalidUserPassword), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeBasicAuthRequestShaper(validUserName, invalidUserPassword), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeBasicAuthRequestShaper(invalidUserName, validUserPassword), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeBasicAuthRequestShaper(adminUserName, validUserPassword), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "POST", path: "/foo", shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "DELETE", path: "/foo", shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "BAR", path: "/foo", shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "GET", path: "/test", shaper: makeBasicAuthRequestShaper(validUserName, validUserPassword), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, } { test.BothEndpoints(t, tc.getTestFunction(rest)) } } func TestTokenAuth(t *testing.T) { ctx := context.Background() rest := testAPIwithBasicAuth(t) defer rest.Shutdown(ctx) for _, tc := range []httpTestcase{ {}, { method: "", path: "", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "POST", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "DELETE", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "HEAD", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "OPTIONS", // Always allowed for CORS path: "/foo", checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "PUT", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "TRACE", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "CONNECT", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "BAR", path: "/foo", checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeTokenAuthRequestShaper(invalidToken), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeTokenAuthRequestShaper(invalidToken), checker: assertHTTPStatusIsUnauthoriazed, }, { method: "GET", path: "/foo", shaper: makeTokenAuthRequestShaper(validToken), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "POST", path: "/foo", shaper: makeTokenAuthRequestShaper(validToken), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "DELETE", path: "/foo", shaper: makeTokenAuthRequestShaper(validToken), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "BAR", path: "/foo", shaper: makeTokenAuthRequestShaper(validToken), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, { method: "GET", path: "/test", shaper: makeTokenAuthRequestShaper(validToken), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsUnauthoriazed), }, } { test.BothEndpoints(t, tc.getTestFunction(rest)) } } func TestLimitMaxHeaderSize(t *testing.T) { maxHeaderBytes := 4 * DefaultMaxHeaderBytes cfg := newTestConfig() cfg.MaxHeaderBytes = maxHeaderBytes ctx := context.Background() rest := testAPIwithConfig(t, cfg, "http with maxHeaderBytes") defer rest.Shutdown(ctx) for _, tc := range []httpTestcase{ { method: "GET", path: "/foo", shaper: makeLongHeaderShaper(maxHeaderBytes * 2), checker: assertHTTPStatusIsTooLarge, }, { method: "GET", path: "/foo", shaper: makeLongHeaderShaper(maxHeaderBytes / 2), checker: makeHTTPStatusNegatedAssert(assertHTTPStatusIsTooLarge), }, } { test.BothEndpoints(t, tc.getTestFunction(rest)) } }