package api import ( "context" "errors" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" ) func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) { for _, initial := range []string{"available", "missing"} { t.Run(initial, func(t *testing.T) { id := "sha256:" + strings.Repeat("a", 64) blocker := "sha256:" + strings.Repeat("b", 64) reached, release := make(chan struct{}), make(chan struct{}) var once sync.Once var reachedOnce sync.Once var deleted atomic.Bool unblock := func() { once.Do(func() { close(release) }) } a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodDelete: deleted.Store(true) writeJSON(w, []any{}) case r.URL.Path == "/v1.47/images/"+id+"/json": if deleted.Load() { w.WriteHeader(http.StatusNotFound) return } writeImageInspect(w, id, "1.2.3") case r.URL.Path == "/v1.47/images/"+blocker+"/json": reachedOnce.Do(func() { close(reached) }) select { case <-release: case <-r.Context().Done(): return } w.WriteHeader(http.StatusNotFound) case r.URL.Path == "/v1.47/images/json", r.URL.Path == "/v1.47/containers/json": writeJSON(w, []any{}) default: t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL) w.WriteHeader(500) } }) defer unblock() seedVerifiedImage(t, a, id, "1.2.3") for _, q := range []struct { sql string args []any }{ {"UPDATE users SET admin=1 WHERE id='u'", nil}, {"INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3',?,'2'),(?,'2.0.0','missing','1')", []any{id, initial, blocker}}, } { if _, err := a.s.DB.Exec(q.sql, q.args...); err != nil { t.Fatal(err) } } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() type outcome struct { images []imageRecord err error } done := make(chan outcome, 1) go func() { images, err := a.imageIndex(ctx); done <- outcome{images, err} }() select { case <-reached: case <-ctx.Done(): t.Fatal("index did not reach second image") } r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+id, nil).WithContext(ctx) r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid}) addTestAccess(r, sid) w := httptest.NewRecorder() a.images(w, r) if w.Code != 200 { t.Fatalf("delete: %d %s", w.Code, w.Body.String()) } unblock() select { case out := <-done: if out.err != nil { t.Fatal(out.err) } matches := 0 for _, im := range out.images { if im.ImageID == id { matches++ } } if matches != 0 { t.Errorf("returned %d records for deleted image, want 0", matches) } case <-ctx.Done(): t.Fatal("index did not finish") } var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM images WHERE image_id=?", id).Scan(&count); err != nil { t.Fatal(err) } if count != 0 { t.Errorf("persisted %d rows for deleted image", count) } }) } } func TestImageIndexBoundsInvalidatedSnapshots(t *testing.T) { var a *API var calls atomic.Int32 a, _ = imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1.47/images/json" { t.Errorf("unexpected request: %s", r.URL) w.WriteHeader(500) return } // Model a completed deletion during every unlocked observation phase. buildQueue.Lock() a.imageDeleteGeneration++ buildQueue.Unlock() calls.Add(1) writeJSON(w, []any{}) }) images, err := a.imageIndex(context.Background()) if !errors.Is(err, errImageIndexChanged) || images != nil { t.Fatalf("images=%v err=%v; want no inventory and invalidation error", images, err) } if calls.Load() != 3 { t.Fatalf("attempts=%d, want 3", calls.Load()) } }