Files
Evan JarrettandClaude Opus 5 2a58ccebd8 hold/gc: name the third ownership state instead of encoding it as a lie
264d332 fixed the behaviour but encoded it badly. manifestBelongsToHold
returned (true, false) for an unreachable hold — "yes, but not really" — a
return value that contradicts itself, and isPredecessorHold both applied the
fail-open policy and handed back the raw material for that policy. The
behaviour was right and the shape was wrong.

The underlying problem is that ownership has three states and the return type
had two:

  ours        - this hold's manifest, or a confirmed predecessor's
  not ours    - the hold answered, and it is someone else's
  unknown     - the hold did not answer; don't delete, but do not adopt

For the first two, "is it ours" and "should its blobs stay referenced" have
the same answer, so one bool worked and the design was never stressed. They
diverge only on unknown. Every version so far has had to collapse unknown
onto one of the other two: before 95d4f7c onto "not ours", which deleted a
live predecessor's blobs, and after it onto "ours", which adopted foreign
manifests and put ten phantom missing layer records on hold01. Same shape
error, opposite sides. That conflation is original, not something 95d4f7c
introduced: manifestBelongsToHold has fed knownManifests since the function
was written.

So name the state. manifestClaim has three values, classifyManifest and
classifyPredecessorHold report what they found and apply no policy, and the
one decision that matters — an unknown claim is carried for blob protection
but never adopted — now sits in the open at the call site instead of two
functions deep, which is how it leaked into ownership to begin with.

No behaviour change from 264d332; the three outcomes and the blob protection
are identical. The regression test was re-verified against this shape: it
fails, reporting the adoption, when claimUnknown is allowed to adopt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWoKzpgtBJ33sCyGxJGR7x
2026-09-01 10:41:04 -05:00

928 lines
28 KiB
Go

package gc
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"atcr.io/pkg/atproto"
)
func TestExtractDigestFromPath(t *testing.T) {
tests := []struct {
name string
path string
expected string
}{
{
name: "valid sha256 path",
path: "/docker/registry/v2/blobs/sha256/ab/abc123def456/data",
expected: "sha256:abc123def456",
},
{
name: "valid sha256 path with full hash",
path: "/docker/registry/v2/blobs/sha256/e3/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/data",
expected: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
name: "invalid path - no data suffix",
path: "/docker/registry/v2/blobs/sha256/ab/abc123def456",
expected: "",
},
{
name: "invalid path - wrong structure",
path: "/some/other/path/data",
expected: "",
},
{
name: "empty path",
path: "",
expected: "",
},
{
name: "uploads temp path (should not match)",
path: "/docker/registry/v2/uploads/temp-uuid/data",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractDigestFromPath(tt.path)
if result != tt.expected {
t.Errorf("extractDigestFromPath(%q) = %q, want %q", tt.path, result, tt.expected)
}
})
}
}
func TestParseATURI(t *testing.T) {
tests := []struct {
name string
uri string
expectNil bool
did string
collection string
rkey string
}{
{
name: "valid AT-URI",
uri: "at://did:plc:abc123/io.atcr.manifest/xyz789",
expectNil: false,
did: "did:plc:abc123",
collection: "io.atcr.manifest",
rkey: "xyz789",
},
{
name: "valid AT-URI with did:web",
uri: "at://did:web:example.com/io.atcr.manifest/manifest123",
expectNil: false,
did: "did:web:example.com",
collection: "io.atcr.manifest",
rkey: "manifest123",
},
{
name: "invalid - no at:// prefix",
uri: "did:plc:abc123/io.atcr.manifest/xyz789",
expectNil: true,
},
{
name: "invalid - missing rkey",
uri: "at://did:plc:abc123/io.atcr.manifest",
expectNil: true,
},
{
name: "invalid - empty string",
uri: "",
expectNil: true,
},
{
name: "invalid - http URL",
uri: "https://example.com/xrpc/com.atproto.repo.getRecord",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseATURI(tt.uri)
if tt.expectNil {
if result != nil {
t.Errorf("parseATURI(%q) = %+v, want nil", tt.uri, result)
}
return
}
if result == nil {
t.Errorf("parseATURI(%q) = nil, want non-nil", tt.uri)
return
}
if result.DID != tt.did {
t.Errorf("parseATURI(%q).DID = %q, want %q", tt.uri, result.DID, tt.did)
}
if result.Collection != tt.collection {
t.Errorf("parseATURI(%q).Collection = %q, want %q", tt.uri, result.Collection, tt.collection)
}
if result.Rkey != tt.rkey {
t.Errorf("parseATURI(%q).Rkey = %q, want %q", tt.uri, result.Rkey, tt.rkey)
}
})
}
}
func TestTidToTime(t *testing.T) {
// Test with known TID format
// TIDs are base32-encoded timestamps with counter
tests := []struct {
name string
tid string
expectZero bool
minAge time.Duration // Minimum expected age (roughly)
}{
{
name: "valid TID from 2024",
tid: "3l7nqy25tks2c", // A real TID from around 2024
expectZero: false,
},
{
name: "invalid TID - too short",
tid: "abc",
expectZero: true,
},
{
name: "invalid TID - empty",
tid: "",
expectZero: true,
},
{
name: "invalid TID - not base32",
tid: "!!!!!!!!!!!!!!",
expectZero: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tidToTime(tt.tid)
if tt.expectZero {
if !result.IsZero() {
t.Errorf("tidToTime(%q) = %v, want zero time", tt.tid, result)
}
return
}
if result.IsZero() {
t.Errorf("tidToTime(%q) = zero time, want non-zero", tt.tid)
}
})
}
}
func TestConfig(t *testing.T) {
t.Run("zero value is disabled", func(t *testing.T) {
cfg := Config{}
if cfg.Enabled {
t.Error("expected zero-value Enabled to be false")
}
})
t.Run("explicit enabled", func(t *testing.T) {
cfg := Config{Enabled: true}
if !cfg.Enabled {
t.Error("expected Enabled to be true")
}
})
}
func TestClassifyManifest(t *testing.T) {
gc := &GarbageCollector{
logger: newTestLogger(),
// Seeded with a definitive answer so the non-matching cases resolve from
// cache instead of the network: this test is about routing, not
// reachability. An unreachable hold deliberately answers the other way,
// covered by TestPredecessorUnresolvedStaysReferenced.
predecessorCache: map[string]bool{
"did:web:other-hold.atcr.io": false,
},
}
holdDID := "did:web:hold01.atcr.io"
tests := []struct {
name string
manifest *atproto.ManifestRecord
want manifestClaim
}{
{
name: "matching HoldDID",
manifest: &atproto.ManifestRecord{
HoldDID: "did:web:hold01.atcr.io",
},
want: claimOurs,
},
{
name: "non-matching HoldDID",
manifest: &atproto.ManifestRecord{
HoldDID: "did:web:other-hold.atcr.io",
},
want: claimNotOurs,
},
{
name: "legacy HoldEndpoint matching",
manifest: &atproto.ManifestRecord{
HoldEndpoint: "https://hold01.atcr.io",
},
want: claimOurs,
},
{
name: "legacy HoldEndpoint non-matching",
manifest: &atproto.ManifestRecord{
HoldEndpoint: "https://other-hold.atcr.io",
},
want: claimNotOurs,
},
{
name: "HoldDID takes precedence over endpoint",
manifest: &atproto.ManifestRecord{
HoldDID: "did:web:hold01.atcr.io",
HoldEndpoint: "https://other-hold.atcr.io",
},
want: claimOurs,
},
{
name: "empty manifest",
manifest: &atproto.ManifestRecord{},
want: claimNotOurs,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := gc.classifyManifest(context.Background(), tt.manifest, holdDID)
if got != tt.want {
t.Errorf("classifyManifest() = %v, want %v", got, tt.want)
}
if got == claimUnknown {
t.Errorf("classifyManifest() = unknown: every case here resolves from cache or " +
"the DID field, so an unknown means a real network call leaked into the test")
}
})
}
}
// A hold that cannot be reached must never be written off as "not a predecessor".
// Doing so drops a live predecessor's entire blob set out of the referenced set,
// and those blobs are long past the grace period that protects recent content, so
// the next run deletes them outright. The inconclusive answer must also stay out
// of predecessorCache, which is never reset: caching it would make a single
// five-second blip permanent for the life of the process.
func TestPredecessorUnresolvedStaysReferenced(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
holdDID := "did:web:hold01.atcr.io"
// .invalid is reserved by RFC 2606 and never resolves.
const unreachable = "did:web:unreachable.invalid"
manifest := &atproto.ManifestRecord{HoldDID: unreachable}
// Don't delete, but do not adopt: claimUnknown is the only answer that keeps
// the blobs referenced without asserting the manifest is ours. claimNotOurs
// would drop its blobs out of the referenced set; claimOurs would adopt it
// into knownManifests, reporting phantom missing layer records and letting
// Reconcile write ownership records for another hold's content.
claim := gc.classifyManifest(context.Background(), manifest, holdDID)
if claim == claimNotOurs {
t.Fatal("unreachable hold treated as not-ours: its blobs would be deleted")
}
if claim != claimUnknown {
t.Errorf("unreachable hold classified %v, want unknown: anything else asserts ownership we cannot verify", claim)
}
if _, cached := gc.predecessorCache[unreachable]; cached {
t.Error("inconclusive check cached in predecessorCache: the outage would outlive itself")
}
if !gc.predecessorUnresolved[unreachable] {
t.Error("inconclusive check not recorded in predecessorUnresolved: it would re-dial per manifest")
}
// Second call must be answered from predecessorUnresolved, not re-dialled.
if again := gc.classifyManifest(context.Background(), manifest, holdDID); again != claimUnknown {
t.Errorf("second call classified %v, want unknown: it disagreed with the first", again)
}
}
// A hold that answers and declares no successor is the one negative worth caching.
func TestPredecessorDefinitiveNegativeIsCached(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"uri":"at://x","value":{"successor":""}}`))
}))
defer srv.Close()
gc := &GarbageCollector{logger: newTestLogger()}
isPredecessor, definitive := gc.checkPredecessorAt(context.Background(), "did:web:example.test", srv.URL)
if isPredecessor {
t.Error("hold with no successor reported as a predecessor")
}
if !definitive {
t.Error("a 200 with a parseable captain record should be definitive")
}
}
func TestFetchUserManifests(t *testing.T) {
holdDID := "did:web:hold.example.com"
// Create a mock PDS server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
collection := r.URL.Query().Get("collection")
if collection != atproto.ManifestCollection {
t.Errorf("unexpected collection: %s", collection)
}
// Return manifests: one for this hold, one for another hold, one multi-arch
response := map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.manifest/abc123",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.manifest",
"repository": "myapp",
"digest": "sha256:abc123",
"holdDid": holdDID,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": []map[string]any{
{"digest": "sha256:layer1", "size": 1000, "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip"},
{"digest": "sha256:layer2", "size": 2000, "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip"},
},
"config": map[string]any{
"digest": "sha256:config1",
"size": 500,
"mediaType": "application/vnd.oci.image.config.v1+json",
},
},
},
{
"uri": "at://did:plc:user1/io.atcr.manifest/def456",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.manifest",
"repository": "otherapp",
"digest": "sha256:def456",
"holdDid": "did:web:other-hold.example.com",
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": []map[string]any{
{"digest": "sha256:layer3", "size": 3000, "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip"},
},
},
},
{
"uri": "at://did:plc:user1/io.atcr.manifest/ghi789",
"cid": "bafyrei3",
"value": map[string]any{
"$type": "io.atcr.manifest",
"repository": "multiarch",
"digest": "sha256:ghi789",
"holdDid": holdDID,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": []map[string]any{
{"digest": "sha256:abc123", "size": 500, "mediaType": "application/vnd.oci.image.manifest.v1+json"},
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
gc := &GarbageCollector{
pds: nil, // Not used directly in fetchUserManifests when we bypass DID resolution
logger: newTestLogger(),
// The other hold is seeded as a definitive non-predecessor. Without this
// it would be unreachable rather than negative, and an unreachable hold
// is deliberately kept referenced (see
// TestPredecessorUnresolvedStaysReferenced), which is not what this test
// is about.
predecessorCache: map[string]bool{
"did:web:other-hold.example.com": false,
},
}
// Call fetchUserManifestsFromEndpoint (bypasses DID resolution)
manifests, err := gc.fetchUserManifestsFromEndpoint(
t.Context(), "did:plc:user1", server.URL, holdDID)
if err != nil {
t.Fatalf("fetchUserManifestsFromEndpoint() error = %v", err)
}
// Should only include manifests for this hold (the regular one and the multi-arch)
if len(manifests) != 2 {
t.Fatalf("expected 2 manifests for this hold, got %d", len(manifests))
}
// First should be the regular manifest with layers
m1 := manifests[0]
if m1.URI != "at://did:plc:user1/io.atcr.manifest/abc123" {
t.Errorf("unexpected URI: %s", m1.URI)
}
if m1.UserDID != "did:plc:user1" {
t.Errorf("unexpected UserDID: %s", m1.UserDID)
}
if len(m1.Record.Layers) != 2 {
t.Errorf("expected 2 layers, got %d", len(m1.Record.Layers))
}
if m1.Record.Config == nil || m1.Record.Config.Digest != "sha256:config1" {
t.Error("expected config digest sha256:config1")
}
// Second should be the multi-arch manifest (no layers, has manifests)
m2 := manifests[1]
if len(m2.Record.Layers) != 0 {
t.Errorf("multi-arch manifest should have no layers, got %d", len(m2.Record.Layers))
}
if len(m2.Record.Manifests) != 1 {
t.Errorf("expected 1 manifest reference, got %d", len(m2.Record.Manifests))
}
}
// TestFetchUserManifests_UnreachableHoldIsProtectOnly pins the split between
// "keep these blobs referenced" and "this manifest is ours". A hold that cannot
// be reached must not be written off, or its blobs are deleted. But it must not
// be adopted either: adopting reports every layer of a foreign manifest as a
// missing layer record, and reconcileMissingRecords acts on exactly that list,
// writing io.atcr.hold.layer records that claim another hold's content.
//
// This is not hypothetical. A dev push to did:web:localhost:8080 leaves a
// manifest whose hold can never resolve from a server, so it is permanently
// indefinite rather than briefly down, and it surfaced on hold01 as ten missing
// layer records for manifests it had never stored.
func TestFetchUserManifests_UnreachableHoldIsProtectOnly(t *testing.T) {
holdDID := "did:web:hold.example.com"
// .invalid is reserved by RFC 2606 and never resolves, standing in for the
// localhost and RFC1918 hold DIDs that dev pushes leave behind.
const unreachable = "did:web:dev-push-stand-in.invalid"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.manifest/ours",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.manifest",
"repository": "ours",
"digest": "sha256:ours",
"holdDid": holdDID,
"layers": []map[string]any{
{"digest": "sha256:ourlayer", "size": 1000},
},
},
},
{
"uri": "at://did:plc:user1/io.atcr.manifest/devpush",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.manifest",
"repository": "valtest",
"digest": "sha256:devpush",
"holdDid": unreachable,
"layers": []map[string]any{
{"digest": "sha256:devlayer", "size": 170},
},
},
},
},
}
_ = json.NewEncoder(w).Encode(response)
}))
defer server.Close()
gc := &GarbageCollector{logger: newTestLogger()}
manifests, err := gc.fetchUserManifestsFromEndpoint(
t.Context(), "did:plc:user1", server.URL, holdDID)
if err != nil {
t.Fatalf("fetchUserManifestsFromEndpoint() error = %v", err)
}
// Both survive: the unreachable one is still carried so its blobs stay
// referenced. Dropping it here is the failure 95d4f7c fixed.
if len(manifests) != 2 {
t.Fatalf("expected 2 manifests (ours, plus the unreachable one kept for blob protection), got %d", len(manifests))
}
byURI := make(map[string]*manifestInfo, len(manifests))
for _, m := range manifests {
byURI[m.URI] = m
}
ours := byURI["at://did:plc:user1/io.atcr.manifest/ours"]
if ours == nil {
t.Fatal("our own manifest was dropped")
}
if ours.ProtectOnly {
t.Error("a directly-matching manifest was marked ProtectOnly: it would never be adopted, " +
"so its genuinely missing layer records would go unreported and unreconciled")
}
dev := byURI["at://did:plc:user1/io.atcr.manifest/devpush"]
if dev == nil {
t.Fatal("unreachable hold's manifest was dropped: its blobs fall out of the referenced set and are deleted")
}
if !dev.ProtectOnly {
t.Error("unreachable hold's manifest was adopted as ours: analyzeRecords would report its " +
"layers as missing layer records and Reconcile would write ownership records for another hold's content")
}
}
func TestFetchUserManifests_Pagination(t *testing.T) {
holdDID := "did:web:hold.example.com"
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
cursor := r.URL.Query().Get("cursor")
var response map[string]any
if cursor == "" {
// First page
response = map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.manifest/page1",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.manifest",
"holdDid": holdDID,
"layers": []map[string]any{{"digest": "sha256:l1", "size": 100}},
},
},
},
"cursor": "page2cursor",
}
} else {
// Second page (no more cursor)
response = map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.manifest/page2",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.manifest",
"holdDid": holdDID,
"layers": []map[string]any{{"digest": "sha256:l2", "size": 200}},
},
},
},
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
gc := &GarbageCollector{logger: newTestLogger()}
manifests, err := gc.fetchUserManifestsFromEndpoint(
t.Context(), "did:plc:user1", server.URL, holdDID)
if err != nil {
t.Fatalf("error = %v", err)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls (pagination), got %d", callCount)
}
if len(manifests) != 2 {
t.Fatalf("expected 2 manifests, got %d", len(manifests))
}
}
func TestFetchUserManifests_PDSError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "internal error")
}))
defer server.Close()
gc := &GarbageCollector{logger: newTestLogger()}
_, err := gc.fetchUserManifestsFromEndpoint(
t.Context(), "did:plc:user1", server.URL, "did:web:hold.example.com")
if err == nil {
t.Fatal("expected error for 500 response")
}
}
func TestExtractDigestFromManifestURI(t *testing.T) {
tests := []struct {
name string
uri string
expected string
}{
{
name: "valid manifest URI",
uri: "at://did:plc:user1/io.atcr.manifest/abc123def456",
expected: "sha256:abc123def456",
},
{
name: "wrong collection",
uri: "at://did:plc:user1/io.atcr.tag/abc123def456",
expected: "",
},
{
name: "invalid URI",
uri: "not-an-at-uri",
expected: "",
},
{
name: "empty URI",
uri: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractDigestFromManifestURI(tt.uri)
if got != tt.expected {
t.Errorf("extractDigestFromManifestURI(%q) = %q, want %q", tt.uri, got, tt.expected)
}
})
}
}
func TestFilterUntaggedManifests(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("keeps tagged manifests, removes untagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/ccc333", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:aaa111": true,
"sha256:ccc333": true,
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 2 {
t.Fatalf("expected 2 kept, got %d", len(result))
}
if result[0].URI != manifests[0].URI || result[1].URI != manifests[2].URI {
t.Errorf("unexpected manifests kept: %v, %v", result[0].URI, result[1].URI)
}
})
t.Run("preserves manifest list children", func(t *testing.T) {
// Manifest list (tagged) references child (untagged)
manifests := []*manifestInfo{
{
URI: "at://did:plc:u1/io.atcr.manifest/index111",
Record: &atproto.ManifestRecord{
Manifests: []atproto.ManifestReference{
{Digest: "sha256:child222", MediaType: "application/vnd.oci.image.manifest.v1+json"},
{Digest: "sha256:child333", MediaType: "application/vnd.oci.image.manifest.v1+json"},
},
},
},
{URI: "at://did:plc:u1/io.atcr.manifest/child222", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/child333", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/orphan444", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:index111": true, // Only the index is tagged
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 3 {
t.Fatalf("expected 3 kept (index + 2 children), got %d", len(result))
}
// orphan444 should be filtered out
for _, m := range result {
if extractDigestFromManifestURI(m.URI) == "sha256:orphan444" {
t.Error("orphan manifest should have been filtered out")
}
}
})
t.Run("removes everything when nothing is tagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 0 {
t.Fatalf("expected 0 kept, got %d", len(result))
}
})
t.Run("keeps everything when all are tagged", func(t *testing.T) {
manifests := []*manifestInfo{
{URI: "at://did:plc:u1/io.atcr.manifest/aaa111", Record: &atproto.ManifestRecord{}},
{URI: "at://did:plc:u1/io.atcr.manifest/bbb222", Record: &atproto.ManifestRecord{}},
}
tagged := map[string]bool{
"sha256:aaa111": true,
"sha256:bbb222": true,
}
result := gc.filterUntaggedManifests(manifests, tagged)
if len(result) != 2 {
t.Fatalf("expected 2 kept, got %d", len(result))
}
})
}
func TestFetchUserProfile(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("returns profile with autoRemoveUntagged", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"value": map[string]any{
"$type": "io.atcr.sailor.profile",
"defaultHold": "did:web:hold.example.com",
"autoRemoveUntagged": true,
"createdAt": "2025-01-01T00:00:00Z",
},
})
}))
defer server.Close()
profile, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile == nil {
t.Fatal("expected non-nil profile")
}
if !profile.AutoRemoveUntagged {
t.Error("expected AutoRemoveUntagged to be true")
}
if profile.DefaultHold != "did:web:hold.example.com" {
t.Errorf("expected DefaultHold %q, got %q", "did:web:hold.example.com", profile.DefaultHold)
}
})
t.Run("returns nil for 404", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
profile, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if profile != nil {
t.Error("expected nil profile for 404")
}
})
t.Run("returns error for server failure", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := gc.fetchUserProfile(t.Context(), server.URL, "did:plc:test")
if err == nil {
t.Fatal("expected error for 500 response")
}
})
}
func TestFetchUserTags(t *testing.T) {
gc := &GarbageCollector{logger: newTestLogger()}
t.Run("returns tagged digests", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_latest",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifest": "at://did:plc:user1/io.atcr.manifest/abc123",
},
},
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_v1",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "v1",
"manifestDigest": "sha256:def456",
},
},
},
})
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tagged) != 2 {
t.Fatalf("expected 2 tagged digests, got %d", len(tagged))
}
if !tagged["sha256:abc123"] {
t.Error("expected sha256:abc123 to be tagged")
}
if !tagged["sha256:def456"] {
t.Error("expected sha256:def456 to be tagged")
}
})
t.Run("handles pagination", func(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_latest",
"cid": "bafyrei1",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifestDigest": "sha256:aaa111",
},
},
},
"cursor": "page2",
})
} else {
json.NewEncoder(w).Encode(map[string]any{
"records": []map[string]any{
{
"uri": "at://did:plc:user1/io.atcr.tag/myapp_v2",
"cid": "bafyrei2",
"value": map[string]any{
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "v2",
"manifestDigest": "sha256:bbb222",
},
},
},
})
}
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls for pagination, got %d", callCount)
}
if len(tagged) != 2 {
t.Fatalf("expected 2 tagged digests, got %d", len(tagged))
}
})
t.Run("returns empty for no tags", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"records": []any{}})
}))
defer server.Close()
tagged, err := gc.fetchUserTags(t.Context(), server.URL, "did:plc:user1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tagged) != 0 {
t.Errorf("expected 0 tagged digests, got %d", len(tagged))
}
})
}
func newTestLogger() *slog.Logger {
return slog.Default().With("component", "gc-test")
}