diff options
Diffstat (limited to 'internal/bunny/api_test.go')
| -rw-r--r-- | internal/bunny/api_test.go | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/internal/bunny/api_test.go b/internal/bunny/api_test.go new file mode 100644 index 0000000..e74a3b9 --- /dev/null +++ b/internal/bunny/api_test.go @@ -0,0 +1,349 @@ +package bunny + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func newTestAPI(srvURL string) *API { + api := NewAPI(srvURL, "zone", "key") + api.http = &http.Client{Transport: rewriteTransport{}} + return api +} + +// rewriteTransport downgrades https://host/... to http://host/... for tests. +type rewriteTransport struct{} + +func (rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + return http.DefaultTransport.RoundTrip(req) +} + +func TestUploadSuccess(t *testing.T) { + var gotKey, gotChecksum, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("AccessKey") + gotChecksum = r.Header.Get("Checksum") + gotPath = r.URL.Path + if r.Header.Get("Content-Type") != "" { + t.Errorf("unexpected Content-Type %q", r.Header.Get("Content-Type")) + } + w.WriteHeader(201) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + if err := api.Upload(context.Background(), "a/b.js", []byte("data")); err != nil { + t.Fatalf("Upload: %v", err) + } + sum := sha256.Sum256([]byte("data")) + wantChecksum := strings.ToUpper(hex.EncodeToString(sum[:])) + if gotKey != "key" || gotChecksum != wantChecksum { + t.Fatalf("headers: key=%q checksum=%q (want %q)", gotKey, gotChecksum, wantChecksum) + } + if gotPath != "/zone/a/b.js" { + t.Fatalf("path = %q", gotPath) + } +} + +func TestUploadRetriesOn429WithRetryAfter(t *testing.T) { + var n int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&n, 1) <= 2 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(429) + return + } + w.WriteHeader(200) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + if err := api.Upload(context.Background(), "x", []byte("d")); err != nil { + t.Fatalf("Upload: %v", err) + } + if n != 3 { + t.Fatalf("attempts = %d, want 3", n) + } +} + +func TestUploadFailsOn429NoRetryAfter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(429) + })) + defer srv.Close() + api := newTestAPI(srv.URL) + if err := api.Upload(context.Background(), "x", []byte("d")); err == nil { + t.Fatal("expected error") + } +} + +func TestUploadFailsOn5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + api := newTestAPI(srv.URL) + if err := api.Upload(context.Background(), "x", []byte("d")); err == nil { + t.Fatal("expected error") + } +} + +func TestUploadRetriesExhausted(t *testing.T) { + var n int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&n, 1) + w.Header().Set("Retry-After", "0") + w.WriteHeader(429) + })) + defer srv.Close() + api := newTestAPI(srv.URL) + if err := api.Upload(context.Background(), "x", []byte("d")); err == nil { + t.Fatal("expected error") + } + if n != 4 { // initial + 3 retries + t.Fatalf("attempts = %d, want 4", n) + } +} + +func TestUploadClassifiesStatus(t *testing.T) { + cases := []struct { + status int + kind errorKind + }{ + {http.StatusUnauthorized, errorKindAuth}, + {http.StatusForbidden, errorKindAuth}, + {http.StatusBadRequest, errorKindInternal}, + {http.StatusNotFound, errorKindInternal}, + {http.StatusInternalServerError, errorKindServer}, + {http.StatusBadGateway, errorKindServer}, + } + for _, tc := range cases { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.status) + })) + api := newTestAPI(srv.URL) + err := api.Upload(context.Background(), "x", []byte("d")) + srv.Close() + + var apiErr *apiError + if !errors.As(err, &apiErr) { + t.Fatalf("status %d: want *APIError, got %T (%v)", tc.status, err, err) + } + if apiErr.kind != tc.kind { + t.Fatalf("status %d: kind = %d, want %d", tc.status, apiErr.kind, tc.kind) + } + if apiErr.status != tc.status { + t.Fatalf("status %d: APIError.Status = %d", tc.status, apiErr.status) + } + } +} + +func TestUploadSurfacesBunnyErrorMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"HttpCode":400,"Message":"checksum mismatch"}`)) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + err := api.Upload(context.Background(), "x", []byte("d")) + + var apiErr *apiError + if !errors.As(err, &apiErr) { + t.Fatalf("want *apiError, got %T (%v)", err, err) + } + if got := apiErr.ErrorVerbose(); !strings.Contains(got, "checksum mismatch") { + t.Fatalf("verbose detail = %q, want it to contain Bunny's Message", got) + } +} + +func TestListParsesFullPaths(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`[ + {"Path":"/zone/assets/","ObjectName":"app.abc.js","Length":14,"IsDirectory":false,"LastChanged":"2023-06-29T12:00:00.000"}, + {"Path":"/zone/assets/","ObjectName":"sub","Length":0,"IsDirectory":true,"LastChanged":"2023-06-29T12:00:00"} + ]`)) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + objs, err := api.listDir(context.Background(), "assets") + if err != nil { + t.Fatalf("listDir: %v", err) + } + if gotPath != "/zone/assets/" { + t.Fatalf("list path = %q, want trailing slash", gotPath) + } + if len(objs) != 2 { + t.Fatalf("objs = %v", objs) + } + if objs[0].Path != "assets/app.abc.js" || objs[0].Size != 14 || objs[0].IsDirectory { + t.Fatalf("file object = %+v", objs[0]) + } + if objs[1].Path != "assets/sub" || !objs[1].IsDirectory { + t.Fatalf("dir object = %+v", objs[1]) + } +} + +func TestListRootTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`[]`)) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + if _, err := api.listDir(context.Background(), ""); err != nil { + t.Fatalf("listDir: %v", err) + } + if gotPath != "/zone/" { + t.Fatalf("root list path = %q, want /zone/", gotPath) + } +} + +func TestWalkBuildsTreeAndDescends(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/zone/": + w.Write([]byte(`[ + {"Path":"/zone/","ObjectName":"index.html","Length":6,"IsDirectory":false}, + {"Path":"/zone/","ObjectName":"assets","IsDirectory":true} + ]`)) + case "/zone/assets/": + w.Write([]byte(`[{"Path":"/zone/assets/","ObjectName":"app.js","Length":3,"IsDirectory":false}]`)) + default: + t.Errorf("unexpected list path %q", r.URL.Path) + } + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + it := api.List(context.Background(), "") + var got []Object + for it.Next() { + got = append(got, it.Object()) + } + if err := it.Err(); err != nil { + t.Fatalf("Walk: %v", err) + } + if len(got) != 3 { + t.Fatalf("got %d entries: %+v", len(got), got) + } + var sawDir, sawNested bool + for _, o := range got { + if o.Path == "assets" && o.IsDirectory { + sawDir = true + } + if o.Path == "assets/app.js" && !o.IsDirectory && o.Size == 3 { + sawNested = true + } + } + if !sawDir || !sawNested { + t.Fatalf("expected descent into assets; got %+v", got) + } +} + +func TestWalk404IsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + it := api.List(context.Background(), "") + for it.Next() { + t.Fatalf("unexpected entry %+v", it.Object()) + } + if err := it.Err(); err != nil { + t.Fatalf("Walk: %v", err) + } +} + +func TestWalkFatalOnNon404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + it := api.List(context.Background(), "") + for it.Next() { + } + if it.Err() == nil { + t.Fatal("expected error") + } +} + +func TestUploadRateLimitedIsTyped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(429) + })) + defer srv.Close() + api := newTestAPI(srv.URL) + err := api.Upload(context.Background(), "x", []byte("d")) + var apiErr *apiError + if !errors.As(err, &apiErr) || apiErr.kind != errorKindRateLimited { + t.Fatalf("want KindRateLimited APIError, got %v", err) + } +} + +func TestDeleteSuccess(t *testing.T) { + var gotMethod, gotKey, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotKey = r.Header.Get("AccessKey") + gotPath = r.URL.Path + w.WriteHeader(200) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + if err := api.Delete(context.Background(), "a/b.js"); err != nil { + t.Fatalf("Delete: %v", err) + } + if gotMethod != http.MethodDelete { + t.Fatalf("method = %q", gotMethod) + } + if gotKey != "key" || gotPath != "/zone/a/b.js" { + t.Fatalf("key=%q path=%q", gotKey, gotPath) + } +} + +func TestDeleteFailsOn5xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer srv.Close() + + api := newTestAPI(srv.URL) + if err := api.Delete(context.Background(), "a/b.js"); err == nil { + t.Fatal("expected error on 5xx") + } +} + +// TestAPIErrorUnwrapsCause ensures a network apiError preserves its cause in the +// error chain, so errors.Is can detect e.g. context.Canceled from an aborted +// in-flight upload. Without this, the worker pool cannot filter cancellation +// collateral out of a fail-fast failure report. +func TestAPIErrorUnwrapsCause(t *testing.T) { + req, err := http.NewRequest(http.MethodPut, "https://example.test/zone/a.js", nil) + if err != nil { + t.Fatal(err) + } + // Simulate a transport error whose cause is context.Canceled. + apiErr := newAPIError(errorKindNetwork, context.Canceled, req, 0) + if !errors.Is(apiErr, context.Canceled) { + t.Fatalf("apiError must unwrap to its cause; got chain that is not context.Canceled") + } +} |