//go:build e2e // End-to-end tests that exercise the compiled `cacher` binary against // a real Garage container. Opt-in via build tag because they require // Docker and pull a ~150 MB image. // // Run with: `just test-e2e` or `go test -tags=e2e -timeout=5m ./...` package main_test import ( "bytes" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "go.bigb.es/cacher/internal/testutil/garage" ) // cacherBinary is built once in TestMain so every subtest exec's the // same artifact. var cacherBinary string func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "cacher-e2e-*") if err != nil { fmt.Fprintln(os.Stderr, "mktmp:", err) os.Exit(1) } cacherBinary = filepath.Join(tmp, "cacher") out, err := exec.Command("go", "build", "-o", cacherBinary, ".").CombinedOutput() if err != nil { fmt.Fprintf(os.Stderr, "build cacher:\n%s\n%v\n", out, err) os.RemoveAll(tmp) os.Exit(1) } code := m.Run() os.RemoveAll(tmp) os.Exit(code) } // runResult captures everything a CI script would see. type runResult struct { stdout, stderr string exit int } // runner returns a closure that invokes the cacher binary with the given // args, pointing at the Garage instance via env vars and a per-test // CACHER_CONFIG path so parallel tests don't fight. func runner(t *testing.T, g *garage.Garage) func(args ...string) runResult { t.Helper() cfgPath := filepath.Join(t.TempDir(), "config.toml") env := append(os.Environ(), "CACHER_S3_KEY_ID="+g.AccessKey, "CACHER_S3_SECRET="+g.SecretKey, ) return func(args ...string) runResult { t.Helper() // Use --config so each test gets its own config.toml — keeps // parallel tests isolated and stops them from clobbering the // developer's real ~/.config/cacher/config.toml. full := append([]string{"--config", cfgPath}, args...) cmd := exec.Command(cacherBinary, full...) cmd.Env = env var so, se bytes.Buffer cmd.Stdout = &so cmd.Stderr = &se err := cmd.Run() exit := 0 if exitErr, ok := err.(*exec.ExitError); ok { exit = exitErr.ExitCode() } else if err != nil { t.Fatalf("exec cacher: %v\nstderr: %s", err, se.String()) } return runResult{stdout: so.String(), stderr: se.String(), exit: exit} } } // initCacher runs `cacher init` against the Garage container. Asserts the // doctor smoke test passes — this is the regression test for the two // Garage quirks (HeadBucket → 403, signature region pickiness) we hit // against real production. func initCacher(t *testing.T, run func(...string) runResult, g *garage.Garage) { t.Helper() r := run("init", "--endpoint", g.Endpoint, "--region", g.Region, "--bucket", g.Bucket, "--prefix", "e2e", "--key-file", writeFile(t, g.AccessKey), "--secret-file", writeFile(t, g.SecretKey), ) if r.exit != 0 { t.Fatalf("init exit=%d\nstdout: %s\nstderr: %s", r.exit, r.stdout, r.stderr) } if !strings.Contains(r.stdout, "doctor: all checks passed") { t.Fatalf("init/doctor smoke test missing success line:\n%s", r.stdout) } } func TestFileRoundTrip(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) src := writeFile(t, "hello world") // Upload, then verify exists, then download, then byte-compare. if r := run("upload", "k1", src); r.exit != 0 { t.Fatalf("upload: exit=%d stderr=%s", r.exit, r.stderr) } if r := run("exists", "k1"); r.exit != 0 { t.Fatalf("exists(present) exit=%d, want 0", r.exit) } dst := filepath.Join(t.TempDir(), "out") if r := run("download", "k1", dst); r.exit != 0 { t.Fatalf("download exit=%d stderr=%s", r.exit, r.stderr) } if got := readFile(t, dst); got != "hello world" { t.Errorf("round-trip body mismatch: %q", got) } // Delete invalidates the cache; exists must now return 1. if r := run("delete", "k1"); r.exit != 0 { t.Fatalf("delete exit=%d stderr=%s", r.exit, r.stderr) } if r := run("exists", "k1"); r.exit != 1 { t.Errorf("exists(missing) exit=%d, want 1", r.exit) } } func TestExitCodes(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) // exists on a missing key → 1 if r := run("exists", "nope"); r.exit != 1 { t.Errorf("exists(missing) exit=%d, want 1", r.exit) } // download with no --url and a missing key → 3 (ErrMissNoFallback) dst := filepath.Join(t.TempDir(), "x") if r := run("download", "nope", dst); r.exit != 3 { t.Errorf("download(miss, no url) exit=%d, want 3", r.exit) } } func TestURLFallbackAndCacheFill(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) want := "payload from upstream" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, want) })) defer srv.Close() // First download misses, falls back to URL, AND populates the cache. dst1 := filepath.Join(t.TempDir(), "first") if r := run("download", "url-key", dst1, "--url", srv.URL); r.exit != 0 { t.Fatalf("download(miss → url) exit=%d stderr=%s", r.exit, r.stderr) } if got := readFile(t, dst1); got != want { t.Errorf("first body=%q want %q", got, want) } // Second download should now HIT the cache. We pass a deliberately- // dead URL so a regression that re-fetches would fail loudly here. dst2 := filepath.Join(t.TempDir(), "second") r := run("download", "url-key", dst2, "--url", "http://127.0.0.1:1/dead") if r.exit != 0 { t.Fatalf("download(hit) exit=%d stderr=%s", r.exit, r.stderr) } if !strings.Contains(r.stderr, "Cache HIT") { t.Errorf("second call did not log Cache HIT:\n%s", r.stderr) } if got := readFile(t, dst2); got != want { t.Errorf("cached body=%q want %q", got, want) } } func TestHashFromMatchesShellSha256sumCutC116(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) src := writeFile(t, "deterministic content") r := run("key", "img/{hash}.tar.zst", "--hash-from", src, "--hash-length", "16") if r.exit != 0 { t.Fatalf("key exit=%d stderr=%s", r.exit, r.stderr) } got := strings.TrimSpace(r.stdout) // Independently compute via shell to verify the parity claim. Skip // if sha256sum isn't on PATH (macOS dev box without coreutils). if _, err := exec.LookPath("sha256sum"); err != nil { t.Skipf("sha256sum unavailable: %v", err) } out, err := exec.Command("sha256sum", src).Output() if err != nil { t.Fatalf("sha256sum: %v", err) } hash := strings.Fields(string(out))[0][:16] want := "img/" + hash + ".tar.zst" if got != want { t.Errorf("key = %q\nwant = %q (sha256sum %s | cut -c1-16)", got, want, src) } } func TestDirRoundTrip(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) src := t.TempDir() writeFileAt(t, filepath.Join(src, "a.txt"), "alpha") writeFileAt(t, filepath.Join(src, "sub/b.txt"), "bravo") if r := run("dir", "upload", "tree-key", src); r.exit != 0 { t.Fatalf("dir upload exit=%d stderr=%s", r.exit, r.stderr) } dst := t.TempDir() if r := run("dir", "download", "tree-key", dst); r.exit != 0 { t.Fatalf("dir download exit=%d stderr=%s", r.exit, r.stderr) } if got := readFile(t, filepath.Join(dst, "a.txt")); got != "alpha" { t.Errorf("a.txt = %q want alpha", got) } if got := readFile(t, filepath.Join(dst, "sub/b.txt")); got != "bravo" { t.Errorf("sub/b.txt = %q want bravo", got) } } func TestListDelimited(t *testing.T) { g := garage.Start(t) run := runner(t, g) initCacher(t, run, g) // Seed: two "directories" plus a file at the top level. src := writeFile(t, "x") run("upload", "dirA/file1", src) run("upload", "dirA/file2", src) run("upload", "dirB/file3", src) run("upload", "topfile", src) r := run("list") if r.exit != 0 { t.Fatalf("list exit=%d stderr=%s", r.exit, r.stderr) } lines := splitLines(r.stdout) expect := map[string]bool{"dirA/": false, "dirB/": false, "topfile": false} for _, l := range lines { if _, ok := expect[l]; ok { expect[l] = true } } for k, seen := range expect { if !seen { t.Errorf("list output missing %q\nfull:\n%s", k, r.stdout) } } r = run("list", "--recursive") if !strings.Contains(r.stdout, "dirA/file1") || !strings.Contains(r.stdout, "dirA/file2") { t.Errorf("recursive list missing nested keys:\n%s", r.stdout) } } // --- helpers --- func writeFile(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "f") if err := os.WriteFile(p, []byte(body), 0o600); err != nil { t.Fatal(err) } return p } func writeFileAt(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } func splitLines(s string) []string { var out []string for _, l := range strings.Split(strings.TrimRight(s, "\n"), "\n") { if l != "" { out = append(out, l) } } return out }