//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
}