package db import ( "database/sql" "errors" "testing" "time" ) // seedCascadeFixture inserts a user and a single manifest. Returns the // manifest's row id so callers can attach references (for the multi-arch case). func seedCascadeFixture(t *testing.T, db *sql.DB, didStr, repo, digest string) string { t.Helper() user := &User{ DID: didStr, Handle: "tester.example.com", PDSEndpoint: "https://test.pds.example.com", LastSeen: time.Now(), } if err := UpsertUser(db, user); err != nil { t.Fatalf("UpsertUser: %v", err) } id, err := InsertManifest(db, &Manifest{ DID: didStr, Repository: repo, Digest: digest, HoldEndpoint: "did:web:hold.example.com", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), }) if err != nil { t.Fatalf("InsertManifest: %v", err) } return id } func TestGetTagDigest_ReturnsDigestForKnownTag(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() const did = "did:plc:tagdigest" const repo = "myapp" const digest = "sha256:aaa" seedCascadeFixture(t, db, did, repo, digest) if err := UpsertTag(db, &Tag{ DID: did, Repository: repo, Tag: "latest", Digest: digest, CreatedAt: time.Now(), }); err != nil { t.Fatalf("UpsertTag: %v", err) } got, err := GetTagDigest(db, did, repo, "latest") if err != nil { t.Fatalf("GetTagDigest: %v", err) } if got != digest { t.Errorf("digest mismatch: got %q want %q", got, digest) } } func TestGetTagDigest_UnknownTagReturnsErrNoRows(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() seedCascadeFixture(t, db, "did:plc:tagdigest2", "myapp", "sha256:bbb") _, err = GetTagDigest(db, "did:plc:tagdigest2", "myapp", "does-not-exist") if !errors.Is(err, sql.ErrNoRows) { t.Errorf("expected sql.ErrNoRows for unknown tag, got %v", err) } } // TestShouldCascadeDeleteManifest_LastTagAndNoParent: the common case — // digest has no remaining tags and is not referenced by any manifest list. // Cascade should fire. func TestShouldCascadeDeleteManifest_LastTagAndNoParent(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() const did = "did:plc:cascade1" const repo = "myapp" const digest = "sha256:lonely" seedCascadeFixture(t, db, did, repo, digest) // No tags pointing to this digest, no manifest_references entries. ok, err := ShouldCascadeDeleteManifest(db, did, digest) if err != nil { t.Fatalf("ShouldCascadeDeleteManifest: %v", err) } if !ok { t.Error("expected cascade=true when manifest is untagged and unreferenced") } } // TestShouldCascadeDeleteManifest_RemainingTagBlocks: another tag still // points to this digest → keep the manifest alive. func TestShouldCascadeDeleteManifest_RemainingTagBlocks(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() const did = "did:plc:cascade2" const repo = "myapp" const digest = "sha256:shared" seedCascadeFixture(t, db, did, repo, digest) if err := UpsertTag(db, &Tag{ DID: did, Repository: repo, Tag: "v1", Digest: digest, CreatedAt: time.Now(), }); err != nil { t.Fatalf("UpsertTag: %v", err) } ok, err := ShouldCascadeDeleteManifest(db, did, digest) if err != nil { t.Fatalf("ShouldCascadeDeleteManifest: %v", err) } if ok { t.Error("expected cascade=false when another tag still points to the digest") } } // TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks: the same digest is // tagged in a *different* repository of the same user. The io.atcr.manifest // record is keyed by digest alone, so one record backs both repos — cascading // here would delete the record out from under the other repo and purge the // shared layers on the hold, breaking an image the user never touched. func TestShouldCascadeDeleteManifest_TagInOtherRepoBlocks(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() const did = "did:plc:cascade4" const deletingRepo = "myapp" const otherRepo = "myapp-mirror" const digest = "sha256:sharedacrossrepos" // Same content pushed to two repositories. seedCascadeFixture(t, db, did, deletingRepo, digest) if _, err := InsertManifest(db, &Manifest{ DID: did, Repository: otherRepo, Digest: digest, HoldEndpoint: "did:web:hold.example.com", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), }); err != nil { t.Fatalf("InsertManifest(otherRepo): %v", err) } // Only the other repo still carries a tag. The deleting repo has none. if err := UpsertTag(db, &Tag{ DID: did, Repository: otherRepo, Tag: "v1", Digest: digest, CreatedAt: time.Now(), }); err != nil { t.Fatalf("UpsertTag: %v", err) } ok, err := ShouldCascadeDeleteManifest(db, did, digest) if err != nil { t.Fatalf("ShouldCascadeDeleteManifest: %v", err) } if ok { t.Error("expected cascade=false when the digest is still tagged in another repository") } } // TestShouldCascadeDeleteManifest_MultiArchChildBlocks: the digest is a child // of a manifest list (multi-arch parent). Even with no tags, deleting it // would orphan the parent's reference, so we must NOT cascade. func TestShouldCascadeDeleteManifest_MultiArchChildBlocks(t *testing.T) { db, err := InitDB(":memory:", LibsqlConfig{}) if err != nil { t.Fatalf("InitDB: %v", err) } defer db.Close() const did = "did:plc:cascade3" const repo = "myapp" const childDigest = "sha256:amd64child" const parentDigest = "sha256:multiarchparent" // Insert the child manifest fixture. seedCascadeFixture(t, db, did, repo, childDigest) // Insert a separate parent (manifest list) and attach a manifest_reference // from parent → child. parentID, err := InsertManifest(db, &Manifest{ DID: did, Repository: repo, Digest: parentDigest, HoldEndpoint: "did:web:hold.example.com", SchemaVersion: 2, MediaType: "application/vnd.oci.image.index.v1+json", CreatedAt: time.Now(), }) if err != nil { t.Fatalf("InsertManifest(parent): %v", err) } if err := InsertManifestReference(db, &ManifestReference{ ManifestKey: parentID, Digest: childDigest, Size: 1234, MediaType: "application/vnd.oci.image.manifest.v1+json", PlatformArchitecture: "amd64", PlatformOS: "linux", ReferenceIndex: 0, }); err != nil { t.Fatalf("InsertManifestReference: %v", err) } ok, err := ShouldCascadeDeleteManifest(db, did, childDigest) if err != nil { t.Fatalf("ShouldCascadeDeleteManifest: %v", err) } if ok { t.Error("expected cascade=false when digest is a child of a manifest list") } }