diff --git a/pkg/appview/db/tag_natural_key_test.go b/pkg/appview/db/tag_natural_key_test.go index 45a7677..cdcd24a 100644 --- a/pkg/appview/db/tag_natural_key_test.go +++ b/pkg/appview/db/tag_natural_key_test.go @@ -148,6 +148,70 @@ func TestDeleteTagsNotInListScopedToDID(t *testing.T) { } } +// TestDeleteTagsNotInListBatchPathScopedToDID covers the DID guard on the +// batched delete, which the test above cannot reach. +// +// A nil keep list takes the early return, so the scoping proven there is the one +// on `DELETE FROM tags WHERE did = ?`, not the one inside the chunk loop. Two +// users owning the same repository:tag is the ordinary case, not a contrived +// one, and if the chunk loop ever stopped scoping by DID then one user's tag +// sync would delete the other's rows for every name they happen to share. +func TestDeleteTagsNotInListBatchPathScopedToDID(t *testing.T) { + database := revTestDB(t) + + const ( + repository = "myapp" + total = BatchSize + 5 // more than one chunk, so every chunk is checked + otherDID = "did:plc:someoneelse" + ) + did := seedTags(t, database, repository, total) + + // The other user owns exactly the same repository:tag pairs. + if err := UpsertUser(database, &User{ + DID: otherDID, + Handle: "other.example.com", + PDSEndpoint: "https://pds.example.com", + LastSeen: time.Now(), + }); err != nil { + t.Fatalf("UpsertUser: %v", err) + } + for i := range total { + if err := UpsertTag(database, &Tag{ + DID: otherDID, + Repository: repository, + Tag: fmt.Sprintf("v%d", i), + Digest: fmt.Sprintf("sha256:%064d", i), + CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("UpsertTag for the other user: %v", err) + } + } + + // Keep one tag, so the keep list is non-empty and the delete set collides + // with every one of the other user's rows but one. + keep := []struct{ Repository, Tag string }{{repository, "v0"}} + if err := DeleteTagsNotInList(database, did, keep); err != nil { + t.Fatalf("DeleteTagsNotInList: %v", err) + } + + remaining, err := GetTagsForDID(database, did) + if err != nil { + t.Fatalf("GetTagsForDID: %v", err) + } + if len(remaining) != 1 { + t.Errorf("owner kept %d tags, want 1", len(remaining)) + } + + otherTags, err := GetTagsForDID(database, otherDID) + if err != nil { + t.Fatalf("GetTagsForDID for the other user: %v", err) + } + if len(otherTags) != total { + t.Errorf("another user's tags were deleted by the batched path: %d remain, want %d", + len(otherTags), total) + } +} + // TestTagNaturalKeyRejectsDuplicates: (did, repository, tag) is the primary key // now, so a repeated upsert must update in place rather than adding a row. func TestTagNaturalKeyRejectsDuplicates(t *testing.T) {