package release import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "os" "sync" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" ) type deleteTrap struct { Store called bool } func (s *deleteTrap) Delete(context.Context, string) error { s.called = true; return nil } func TestConditionalDeleteUnsupportedAndInvalidTags(t *testing.T) { ctx := context.Background() trap := &deleteTrap{} for _, s := range []Store{trap, NewPrefixStore(trap, "scope"), NewPrefixStore(NewPrefixStore(trap, "outer"), "inner")} { if e := DeleteIfMatch(ctx, s, "key", "etag"); !errors.Is(e, ErrConditionalDeleteUnsupported) { t.Fatal(e) } } if trap.called { t.Fatal("unconditional fallback") } d := NewDirStore(t.TempDir()) etag, e := d.Put(ctx, "key", []byte("keep"), "") if e != nil { t.Fatal(e) } for _, bad := range []string{"", " ", "*", `"*"`, `""`, `W/"etag"`, `"one", "two"`, "one,two", "etag\r\nX-Test: bad", " etag", "etag ", "\tetag", `"broken`, `broken"`} { for _, fn := range []func() error{func() error { return DeleteIfMatch(ctx, d, "key", bad) }, func() error { return d.DeleteIfMatch(ctx, "key", bad) }, func() error { return (&S3Store{}).DeleteIfMatch(ctx, "key", bad) }} { if e := fn(); !errors.Is(e, ErrInvalidETag) { t.Fatalf("invalid tag accepted: %q: %v", bad, e) } } } b, got, e := d.Get(ctx, "key") if e != nil || string(b) != "keep" || got != etag { t.Fatal("invalid tag changed object") } } func TestConditionalDeleteDirRevisionAndPrefix(t *testing.T) { ctx := context.Background() d := NewDirStore(t.TempDir()) scoped := NewPrefixStore(NewPrefixStore(d, "outer"), "inner") old, e := scoped.Put(ctx, "key", []byte("old"), "") if e != nil { t.Fatal(e) } current, e := scoped.Put(ctx, "key", []byte("new"), old) if e != nil { t.Fatal(e) } if _, e = d.Put(ctx, "key", []byte("outside"), ""); e != nil { t.Fatal(e) } if e = DeleteIfMatch(ctx, scoped, "key", old); !errors.Is(e, ErrCASConflict) { t.Fatal(e) } b, _, e := d.Get(ctx, "outer/inner/key") if e != nil || string(b) != "new" { t.Fatal("stale tag deleted current object") } if e = DeleteIfMatch(ctx, scoped, "key", current); e != nil { t.Fatal(e) } if _, _, e = d.Get(ctx, "outer/inner/key"); !errors.Is(e, os.ErrNotExist) { t.Fatal(e) } if b, _, e = d.Get(ctx, "key"); e != nil || string(b) != "outside" { t.Fatal("scope escaped") } if e = DeleteIfMatch(ctx, scoped, "key", current); e != nil { t.Fatal("absence not idempotent", e) } if e = DeleteIfMatch(ctx, d, "../escape", current); e == nil { t.Fatal("traversal accepted") } } func TestConditionalDeleteDirRacesPut(t *testing.T) { ctx := context.Background() d := NewDirStore(t.TempDir()) for i := 0; i < 100; i++ { key := fmt.Sprint(i) old, e := d.Put(ctx, key, []byte("old"), "") if e != nil { t.Fatal(e) } start := make(chan struct{}) results := make(chan error, 2) go func() { <-start; _, e := d.Put(ctx, key, []byte("new"), old); results <- e }() go func() { <-start; results <- d.DeleteIfMatch(ctx, key, old) }() close(start) a, b := <-results, <-results if (a == nil) == (b == nil) { t.Fatal("both operations won or lost", a, b) } if a != nil && !errors.Is(a, ErrCASConflict) || b != nil && !errors.Is(b, ErrCASConflict) { t.Fatal(a, b) } body, _, e := d.Get(ctx, key) if e != nil && !errors.Is(e, os.ErrNotExist) { t.Fatal(e) } if e == nil && string(body) != "new" { t.Fatal("unexpected surviving revision") } } } func TestConditionalDeleteS3ActualWire(t *testing.T) { for _, absentStatus := range []int{204, 404} { t.Run(fmt.Sprint(absentStatus), func(t *testing.T) { var mu sync.Mutex objects := map[string]string{"/bucket/scope/key": "current", "/bucket/key": "outside"} calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() calls++ if r.Method != "DELETE" { t.Error("unexpected pre-read or method", r.Method) w.WriteHeader(405) return } if len(r.Header.Values("If-Match")) != 1 || r.Header.Get("If-Match") == "" || r.Header.Get("If-None-Match") != "" { t.Error("missing exact condition") w.WriteHeader(400) return } etag, exists := objects[r.URL.Path] if !exists { if absentStatus == 404 { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(404) fmt.Fprint(w, `NoSuchKey`) } else { w.WriteHeader(204) } return } if r.Header.Get("If-Match") != `"`+etag+`"` { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(412) fmt.Fprint(w, `PreconditionFailed`) return } delete(objects, r.URL.Path) w.WriteHeader(204) })) defer srv.Close() s := &S3Store{Bucket: "bucket", Client: awss3.New(awss3.Options{Region: "us-east-1", Credentials: credentials.NewStaticCredentialsProvider("fixture", "fixture", ""), BaseEndpoint: aws.String(srv.URL), UsePathStyle: true, RetryMaxAttempts: 1})} ctx := context.Background() scoped := NewPrefixStore(s, "scope") if e := DeleteIfMatch(ctx, scoped, "key", `"stale"`); !errors.Is(e, ErrCASConflict) { t.Fatal(e) } mu.Lock() retained := objects["/bucket/scope/key"] mu.Unlock() if retained != "current" { t.Fatal("wrong tag deleted object") } if e := DeleteIfMatch(ctx, scoped, "key", `"current"`); e != nil { t.Fatal(e) } if e := DeleteIfMatch(ctx, scoped, "key", `"current"`); e != nil { t.Fatal("absence not accepted", e) } mu.Lock() defer mu.Unlock() if _, ok := objects["/bucket/scope/key"]; ok || objects["/bucket/key"] != "outside" || calls != 3 { t.Fatal("conditional scope or request count", objects, calls) } }) } }