package depot import ( "context" "crypto/sha256" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" ) func shaOf(s string) string { return fmt.Sprintf("%x", sha256.Sum256([]byte(s))) } type recordedReq struct { Method string Path string Range string Headers http.Header } // fakeBunny is a minimal recording fake for Bunny storage/CDN endpoints. type fakeBunny struct { mu sync.Mutex requests []recordedReq headCount int blobs map[string][]byte statusFor map[string]int // sha -> status code override for probe/get } func newFakeBunny() *fakeBunny { return &fakeBunny{blobs: map[string][]byte{}, statusFor: map[string]int{}} } func (f *fakeBunny) handler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() f.requests = append(f.requests, recordedReq{ Method: r.Method, Path: r.URL.Path, Range: r.Header.Get("Range"), Headers: r.Header.Clone(), }) if r.Method == http.MethodHead { f.headCount++ f.mu.Unlock() w.WriteHeader(500) return } f.mu.Unlock() sha := strings.TrimPrefix(r.URL.Path, "/") // last path element is the sha parts := strings.Split(sha, "/") sha = parts[len(parts)-1] if r.Method == http.MethodPut { buf, _ := io.ReadAll(r.Body) f.mu.Lock() f.blobs[sha] = buf f.mu.Unlock() w.WriteHeader(201) return } // GET (range probe / actual get) f.mu.Lock() status, hasStatus := f.statusFor[sha] data, ok := f.blobs[sha] f.mu.Unlock() if hasStatus { w.WriteHeader(status) return } if !ok { w.WriteHeader(404) return } w.WriteHeader(206) _, _ = w.Write(data) } } func (f *fakeBunny) requestsSnapshot() []recordedReq { f.mu.Lock() defer f.mu.Unlock() out := make([]recordedReq, len(f.requests)) copy(out, f.requests) return out } func testCfg(storageHost, readKey, writeKey string) Config { return Config{ CDNBase: "http://127.0.0.1:1", // unreachable by default; overridden per test StorageHost: storageHost, StorageZone: "sow-assets-depot", ReadKey: readKey, WriteKey: writeKey, ProbeJobs: 4, Jobs: 4, ConnectTimeout: 2 * time.Second, ProbeMaxTime: 2 * time.Second, ConfirmRetries: 3, ConfirmMax: 200, } } // hostPort returns a StorageHost value for tests: a full "http://host:port" // base URL, which storageURL() passes through unchanged (bypassing the // production https:// default so httptest.NewServer works without TLS). func hostPort(srv *httptest.Server) string { return srv.URL } func TestProbeUsesRangeGetNotHead(t *testing.T) { sha := shaOf("present-blob") f := newFakeBunny() f.blobs[sha] = []byte("hello") srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkey", "writekey") b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } state, transient, err := b.Probe(context.Background(), sha) if err != nil { t.Fatalf("Probe: %v", err) } if state != Present || transient { t.Fatalf("expected Present/non-transient, got %v/%v", state, transient) } reqs := f.requestsSnapshot() if len(reqs) != 1 { t.Fatalf("expected exactly 1 request, got %d", len(reqs)) } if reqs[0].Method != "GET" { t.Fatalf("expected GET, got %s", reqs[0].Method) } if reqs[0].Range != "bytes=0-0" { t.Fatalf("expected Range bytes=0-0, got %q", reqs[0].Range) } if f.headCount != 0 { t.Fatalf("expected zero HEAD requests, got %d", f.headCount) } } func TestProbeStates(t *testing.T) { cases := []struct { status int wantState ProbeState wantTransient bool }{ {200, Present, false}, {206, Present, false}, {404, Absent, false}, {428, Unconfirmed, true}, {503, Unconfirmed, true}, } for _, c := range cases { t.Run(fmt.Sprintf("status_%d", c.status), func(t *testing.T) { sha := shaOf(fmt.Sprintf("blob-%d", c.status)) f := newFakeBunny() f.statusFor[sha] = c.status srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkey", "writekey") b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } state, transient, err := b.Probe(context.Background(), sha) if err != nil && c.status < 500 { t.Fatalf("unexpected error: %v", err) } if state != c.wantState { t.Fatalf("status %d: expected state %v, got %v", c.status, c.wantState, state) } if transient != c.wantTransient { t.Fatalf("status %d: expected transient=%v, got %v", c.status, c.wantTransient, transient) } if f.headCount != 0 { t.Fatalf("expected zero HEAD requests, got %d", f.headCount) } }) } } func TestBunnyPutSequence(t *testing.T) { t.Run("absent sha", func(t *testing.T) { sha := shaOf("new-blob") f := newFakeBunny() srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkey", "writekey") b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } dir := t.TempDir() srcPath := filepath.Join(dir, "src") if err := os.WriteFile(srcPath, []byte("new-blob-content"), 0644); err != nil { t.Fatal(err) } if err := b.Put(context.Background(), sha, srcPath); err != nil { t.Fatalf("Put: %v", err) } reqs := f.requestsSnapshot() if len(reqs) != 2 { t.Fatalf("expected 2 requests (probe, put), got %d: %+v", len(reqs), reqs) } if reqs[0].Method != "GET" || reqs[0].Range != "bytes=0-0" { t.Fatalf("expected first request to be range probe GET, got %+v", reqs[0]) } if reqs[1].Method != "PUT" { t.Fatalf("expected second request to be PUT, got %+v", reqs[1]) } if got := reqs[1].Headers.Get("Checksum"); got != strings.ToUpper(sha) { t.Fatalf("expected Checksum %s, got %s", strings.ToUpper(sha), got) } if got := reqs[1].Headers.Get("AccessKey"); got != "writekey" { t.Fatalf("expected AccessKey writekey, got %s", got) } if f.headCount != 0 { t.Fatalf("expected zero HEAD requests, got %d", f.headCount) } }) t.Run("present sha", func(t *testing.T) { sha := shaOf("existing-blob") f := newFakeBunny() f.blobs[sha] = []byte("existing-blob-content") srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkey", "writekey") b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } dir := t.TempDir() srcPath := filepath.Join(dir, "src") if err := os.WriteFile(srcPath, []byte("existing-blob-content"), 0644); err != nil { t.Fatal(err) } if err := b.Put(context.Background(), sha, srcPath); err != nil { t.Fatalf("Put: %v", err) } reqs := f.requestsSnapshot() if len(reqs) != 1 { t.Fatalf("expected probe-only (1 request) since present, got %d: %+v", len(reqs), reqs) } if reqs[0].Method != "GET" { t.Fatalf("expected GET probe, got %+v", reqs[0]) } }) } func TestReadWriteKeySplit(t *testing.T) { sha := shaOf("split-blob") f := newFakeBunny() srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkeyXXX", "writekeyYYY") b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } dir := t.TempDir() srcPath := filepath.Join(dir, "src") if err := os.WriteFile(srcPath, []byte("split-blob-content"), 0644); err != nil { t.Fatal(err) } if err := b.Put(context.Background(), sha, srcPath); err != nil { t.Fatalf("Put: %v", err) } reqs := f.requestsSnapshot() if len(reqs) != 2 { t.Fatalf("expected 2 requests, got %d", len(reqs)) } if got := reqs[0].Headers.Get("AccessKey"); got != "readkeyXXX" { t.Fatalf("probe expected AccessKey readkeyXXX, got %s", got) } if got := reqs[1].Headers.Get("AccessKey"); got != "writekeyYYY" { t.Fatalf("put expected AccessKey writekeyYYY, got %s", got) } if reqs[0].Headers.Get("AccessKey") == reqs[1].Headers.Get("AccessKey") { t.Fatalf("expected distinct read/write keys") } } func TestBunnyNoReadKeyFailsClosed(t *testing.T) { // Grep-based tripwire: no non-test depot source may reference stdin/tty. dir := "." entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("ReadFile %s: %v", name, err) } s := string(data) if strings.Contains(s, "os.Stdin") || strings.Contains(s, "bufio.NewReader(os.Stdin)") || strings.Contains(s, "/dev/tty") { t.Fatalf("%s references stdin/tty; credential prompts are banned", name) } } // Confirm undrained pipe: swap in an os.Pipe as stdin-equivalent to prove // no read occurs. r, w, err := os.Pipe() if err != nil { t.Fatalf("Pipe: %v", err) } oldStdin := os.Stdin os.Stdin = r defer func() { os.Stdin = oldStdin; r.Close() }() cfg := testCfg("storage.example.com", "", "writekey") _, err = NewBackend("bunny", "", cfg) if err == nil { t.Fatal("expected error for empty ReadKey") } if !strings.Contains(err.Error(), "ReadKey") && !strings.Contains(err.Error(), "BUNNY_STORAGE_READ_PASSWORD") && !strings.Contains(err.Error(), "BUNNY_STORAGE_PASSWORD") { t.Fatalf("expected error to mention missing env var, got: %v", err) } w.Close() // If code read from stdin it would have blocked already (pipe with no // writer-side data yet); assert the pipe is still open/undrained by // writing now and reading it back ourselves. if _, err := w.Write([]byte("x")); err == nil { t.Fatal("expected write to closed pipe writer to fail") } } func TestBunnyNoStorageHostFailsClosed(t *testing.T) { cfg := testCfg("", "readkey", "writekey") _, err := NewBackend("bunny", "", cfg) if err == nil { t.Fatal("expected error for empty StorageHost") } if !strings.Contains(err.Error(), "StorageHost") && !strings.Contains(err.Error(), "BUNNY_STORAGE_HOST") { t.Fatalf("expected error to mention missing StorageHost/BUNNY_STORAGE_HOST, got: %v", err) } } func TestGetHashMismatchDeletes(t *testing.T) { sha := shaOf("get-mismatch-blob") f := newFakeBunny() f.blobs[sha] = []byte("wrong content") srv := httptest.NewServer(f.handler()) defer srv.Close() cfg := testCfg(hostPort(srv), "readkey", "writekey") // force CDN unreachable so Get falls back to storage cfg.CDNBase = "http://127.0.0.1:1" b, err := NewBackend("bunny", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } dir := t.TempDir() dest := filepath.Join(dir, "dest") err = b.Get(context.Background(), sha, dest) if err == nil { t.Fatal("expected hash mismatch error") } if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) { t.Fatalf("expected dest to be removed on mismatch, stat err: %v", statErr) } } func TestCDNPutReadOnly(t *testing.T) { cfg := testCfg("storage.example.com", "readkey", "writekey") b, err := NewBackend("cdn", "", cfg) if err != nil { t.Fatalf("NewBackend: %v", err) } dir := t.TempDir() srcPath := filepath.Join(dir, "src") if err := os.WriteFile(srcPath, []byte("data"), 0644); err != nil { t.Fatal(err) } err = b.Put(context.Background(), shaOf("anything"), srcPath) if err == nil { t.Fatal("expected error for cdn Put (read-only)") } if !strings.Contains(err.Error(), "read-only") { t.Fatalf("expected read-only error, got: %v", err) } } // TestNoHeadInSources is the anti-HEAD regression tripwire from field note 3: // grep-assert no http.MethodHead/.Head( usage in non-test depot sources. func TestNoHeadInSources(t *testing.T) { dir := "." entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } data, err := os.ReadFile(filepath.Join(dir, name)) if err != nil { t.Fatalf("ReadFile %s: %v", name, err) } s := string(data) if strings.Contains(s, "http.MethodHead") || strings.Contains(s, ".Head(") { t.Fatalf("%s references HEAD probe (banned)", name) } } } // TestLocalBackendViaFactory sanity-checks NewBackend("local", ...). func TestLocalBackendViaFactory(t *testing.T) { root := t.TempDir() b, err := NewBackend("local", root, Config{}) if err != nil { t.Fatalf("NewBackend: %v", err) } lb, ok := b.(*LocalBackend) if !ok { t.Fatalf("expected *LocalBackend, got %T", b) } if lb.Root != root { t.Fatalf("expected Root=%s, got %s", root, lb.Root) } if _, err := NewBackend("local", "", Config{}); err == nil { t.Fatal("expected error for empty local root") } if _, err := NewBackend("nonsense", "", Config{}); err == nil { t.Fatal("expected error for unknown backend name") } }