package depot import ( "bytes" "fmt" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // TestIntegrationPushStatusPull drives status/push/pull end-to-end against a // fake Bunny backend (real bunny HTTP path, in-memory blob store) since the // command surface has no local->local push (push --target must be bunny). func TestIntegrationPushStatusPull(t *testing.T) { manifestsDir := t.TempDir() sourceDir := t.TempDir() blobs := map[string]string{ shaOf("blob-one"): "blob-one", shaOf("blob-two"): "blob-two", shaOf("blob-three"): "blob-three", } var manifest strings.Builder manifest.WriteString("assets:\n") for sha, content := range blobs { manifest.WriteString(fmt.Sprintf(" - path: %s\n sha256: %s\n size: %d\n", sha, sha, len(content))) blobPath := filepath.Join(sourceDir, BlobKey(sha)) if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(blobPath, []byte(content), 0644); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(manifestsDir, "manifest.yml"), []byte(manifest.String()), 0644); err != nil { t.Fatal(err) } f := newFakeBunny() // Pre-seed one of the three blobs on the target so status starts with drift=2. var oneSHA string for sha := range blobs { oneSHA = sha break } f.blobs[oneSHA] = []byte(blobs[oneSHA]) srv := httptest.NewServer(f.handler()) defer srv.Close() env := map[string]string{ "BUNNY_STORAGE_HOST": hostPort(srv), "BUNNY_STORAGE_READ_PASSWORD": "readkey", "BUNNY_STORAGE_PASSWORD": "writekey", } getenv := testGetenv(env) // status: expect exit 1, missing=2 var out, errb bytes.Buffer code := Run([]string{"status", "--manifests", manifestsDir, "--target", "bunny"}, &out, &errb, getenv) if code != 1 { t.Fatalf("status: expected 1, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) } if !bytesContains(out.String(), "missing=2") { t.Fatalf("status: expected missing=2, got %s", out.String()) } // push: uploads the 2 missing blobs out.Reset() errb.Reset() code = Run([]string{"push", "--manifests", manifestsDir, "--source", sourceDir, "--target", "bunny"}, &out, &errb, getenv) if code != 0 { t.Fatalf("push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) } if !bytesContains(out.String(), "uploaded=2 failed=0") { t.Fatalf("push: expected uploaded=2 failed=0, got %s", out.String()) } putCountAfterFirstPush := 0 for _, r := range f.requestsSnapshot() { if r.Method == "PUT" { putCountAfterFirstPush++ } } if putCountAfterFirstPush != 2 { t.Fatalf("expected 2 PUTs after first push, got %d", putCountAfterFirstPush) } // status: now clean out.Reset() errb.Reset() code = Run([]string{"status", "--manifests", manifestsDir, "--target", "bunny"}, &out, &errb, getenv) if code != 0 { t.Fatalf("status after push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) } // push again: idempotent, no new PUTs, uploaded=0 out.Reset() errb.Reset() code = Run([]string{"push", "--manifests", manifestsDir, "--source", sourceDir, "--target", "bunny"}, &out, &errb, getenv) if code != 0 { t.Fatalf("second push: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) } if !bytesContains(out.String(), "uploaded=0 failed=0") { t.Fatalf("second push: expected uploaded=0 failed=0, got %s", out.String()) } putCountAfterSecondPush := 0 for _, r := range f.requestsSnapshot() { if r.Method == "PUT" { putCountAfterSecondPush++ } } if putCountAfterSecondPush != putCountAfterFirstPush { t.Fatalf("expected no additional PUTs on idempotent push, before=%d after=%d", putCountAfterFirstPush, putCountAfterSecondPush) } // pull into a dest dir: one pre-populated correct file (skipped, no GET), // one pre-populated corrupt file (re-downloaded), one absent (downloaded). destDir := t.TempDir() shas := make([]string, 0, len(blobs)) for sha := range blobs { shas = append(shas, sha) } correctSHA := shas[0] corruptSHA := shas[1] // absentSHA := shas[2] // left absent on purpose correctPath := filepath.Join(destDir, BlobKey(correctSHA)) if err := os.MkdirAll(filepath.Dir(correctPath), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(correctPath, []byte(blobs[correctSHA]), 0644); err != nil { t.Fatal(err) } corruptPath := filepath.Join(destDir, BlobKey(corruptSHA)) if err := os.MkdirAll(filepath.Dir(corruptPath), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(corruptPath, []byte("corrupted-content"), 0644); err != nil { t.Fatal(err) } getCountBeforePull := 0 for _, r := range f.requestsSnapshot() { if r.Method == "GET" && !strings.Contains(r.Range, "0-0") { getCountBeforePull++ } } out.Reset() errb.Reset() code = Run([]string{"pull", "--manifests", manifestsDir, "--dest", destDir, "--target", "bunny"}, &out, &errb, getenv) if code != 0 { t.Fatalf("pull: expected 0, got %d (stdout=%s stderr=%s)", code, out.String(), errb.String()) } if !bytesContains(out.String(), "pulled=2 present=1") { t.Fatalf("pull: expected pulled=2 present=1, got %s", out.String()) } // verify the correct pre-existing file was never re-fetched with a full GET. fullGETsForCorrect := 0 for _, r := range f.requestsSnapshot() { if r.Method == "GET" && strings.HasSuffix(r.Path, correctSHA) && r.Range != "bytes=0-0" { fullGETsForCorrect++ } } if fullGETsForCorrect != 0 { t.Fatalf("expected no full GET for already-correct blob, got %d", fullGETsForCorrect) } // all three blobs should now be present and correct in destDir. for sha, content := range blobs { got, err := os.ReadFile(filepath.Join(destDir, BlobKey(sha))) if err != nil { t.Fatalf("dest blob %s: %v", sha, err) } if string(got) != content { t.Fatalf("dest blob %s: expected %q, got %q", sha, content, string(got)) } } }