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") } }