mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
Completes the swap 0033 set up. layers and manifest_references move onto manifest_key and manifests.id is gone, which removes the last node-allocated identifier in the AppView schema. Statement order in 0034 is load-bearing. With foreign keys on, DROP TABLE performs an implicit DELETE FROM, so dropping manifests while layers still holds an ON DELETE CASCADE reference deletes every layer row. Migration 0009 did exactly that; it went unnoticed because the Jetstream backfill rebuilds layers from PDS records, so the damage healed itself. PRAGMA foreign_keys is no help: it is a no-op inside a transaction and migrations run in one. So the new children are built pointing at manifests_new, the old children are dropped first, and only then is the old manifests table dropped, by which point nothing references it. Verified both behaviors before relying on them. manifest_key is declared NOT NULL as well as PRIMARY KEY, because in SQLite a PRIMARY KEY column still accepts NULL unless it is INTEGER PRIMARY KEY. That constraint immediately caught four test helpers inserting manifests without one. Five queries used MAX(id) as "the newest manifest in this repo", which I had previously reported as absent after grepping only for ORDER BY. A derived key has no ordering, so recency now comes from created_at with manifest_key as a deterministic tiebreak. This is a real behavior change, and a fix: the two disagree whenever a manifest is indexed out of order, which the backfill does routinely, and created_at is the push time these queries always wanted. Both directions are tested, including that ties resolve the same way every run. InsertManifest and BatchInsertManifests no longer read anything back. The key is derived from (did, repository, digest), so the writer knows it before the statement runs: the select-back, its per-DID IN list, and the "manifest missing id after batch insert" branch all go away, along with the UNIQUE-conflict fallback that existed only to recover a rowid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1994 lines
63 KiB
Go
1994 lines
63 KiB
Go
package db
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGetRepositoryMetadata(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Insert test user
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "testuser.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
Avatar: "",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to insert user: %v", err)
|
|
}
|
|
|
|
// Test 1: No manifests - should return empty map
|
|
metadata, err := GetRepositoryMetadata(db, testUser.DID, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for nonexistent repo, got: %v", err)
|
|
}
|
|
if len(metadata) != 0 {
|
|
t.Errorf("Expected empty map for nonexistent repository, got %d entries", len(metadata))
|
|
}
|
|
|
|
// Test 2: Insert manifest and annotations
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifests (manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ManifestKey(testUser.DID, "myapp", "sha256:abc123"),
|
|
testUser.DID, "myapp", "sha256:abc123", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
|
|
time.Now().Add(-2*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
|
|
// Insert annotations separately
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{
|
|
"org.opencontainers.image.title": "My App",
|
|
"org.opencontainers.image.description": "A cool application",
|
|
"org.opencontainers.image.source": "https://github.com/user/myapp",
|
|
"org.opencontainers.image.documentation": "https://docs.example.com",
|
|
"org.opencontainers.image.licenses": "MIT",
|
|
"io.atcr.icon": "https://example.com/icon.png",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert annotations: %v", err)
|
|
}
|
|
|
|
// Test 3: Retrieve metadata
|
|
metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get repository metadata: %v", err)
|
|
}
|
|
|
|
if metadata["org.opencontainers.image.title"] != "My App" {
|
|
t.Errorf("Expected title 'My App', got '%s'", metadata["org.opencontainers.image.title"])
|
|
}
|
|
if metadata["org.opencontainers.image.description"] != "A cool application" {
|
|
t.Errorf("Expected description 'A cool application', got '%s'", metadata["org.opencontainers.image.description"])
|
|
}
|
|
if metadata["org.opencontainers.image.source"] != "https://github.com/user/myapp" {
|
|
t.Errorf("Expected sourceURL 'https://github.com/user/myapp', got '%s'", metadata["org.opencontainers.image.source"])
|
|
}
|
|
if metadata["org.opencontainers.image.documentation"] != "https://docs.example.com" {
|
|
t.Errorf("Expected documentationURL 'https://docs.example.com', got '%s'", metadata["org.opencontainers.image.documentation"])
|
|
}
|
|
if metadata["org.opencontainers.image.licenses"] != "MIT" {
|
|
t.Errorf("Expected licenses 'MIT', got '%s'", metadata["org.opencontainers.image.licenses"])
|
|
}
|
|
if metadata["io.atcr.icon"] != "https://example.com/icon.png" {
|
|
t.Errorf("Expected iconURL 'https://example.com/icon.png', got '%s'", metadata["io.atcr.icon"])
|
|
}
|
|
|
|
// Test 4: Insert newer manifest with different annotations
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifests (manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ManifestKey(testUser.DID, "myapp", "sha256:def456"),
|
|
testUser.DID, "myapp", "sha256:def456", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json",
|
|
time.Now()) // Most recent
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert newer manifest: %v", err)
|
|
}
|
|
|
|
// Update annotations with new values (simulates latest manifest having different annotations)
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{
|
|
"org.opencontainers.image.title": "My App v2",
|
|
"org.opencontainers.image.description": "An even cooler application",
|
|
"org.opencontainers.image.source": "https://github.com/user/myapp-v2",
|
|
"org.opencontainers.image.documentation": "https://v2.docs.example.com",
|
|
"org.opencontainers.image.licenses": "Apache-2.0",
|
|
"io.atcr.icon": "https://example.com/icon-v2.png",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to update annotations: %v", err)
|
|
}
|
|
|
|
// Test 5: Should return metadata from most recent manifest
|
|
metadata, err = GetRepositoryMetadata(db, testUser.DID, "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get repository metadata: %v", err)
|
|
}
|
|
|
|
if metadata["org.opencontainers.image.title"] != "My App v2" {
|
|
t.Errorf("Expected title from newest manifest 'My App v2', got '%s'", metadata["org.opencontainers.image.title"])
|
|
}
|
|
if metadata["org.opencontainers.image.description"] != "An even cooler application" {
|
|
t.Errorf("Expected description from newest manifest, got '%s'", metadata["org.opencontainers.image.description"])
|
|
}
|
|
if metadata["org.opencontainers.image.licenses"] != "Apache-2.0" {
|
|
t.Errorf("Expected licenses 'Apache-2.0', got '%s'", metadata["org.opencontainers.image.licenses"])
|
|
}
|
|
|
|
// Test 6: Manifest with NULL metadata fields
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifests (manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ManifestKey(testUser.DID, "minimal-app", "sha256:minimal"),
|
|
testUser.DID, "minimal-app", "sha256:minimal", "did:web:hold.example.com", 2, "application/vnd.oci.image.manifest.v1+json", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert minimal manifest: %v", err)
|
|
}
|
|
|
|
// Test 7: Should handle missing annotations gracefully
|
|
metadata, err = GetRepositoryMetadata(db, testUser.DID, "minimal-app")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get repository metadata for minimal app: %v", err)
|
|
}
|
|
|
|
if len(metadata) != 0 {
|
|
t.Error("Expected empty map for manifest with no annotations")
|
|
}
|
|
}
|
|
|
|
func TestInsertManifest(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Insert test user
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "testuser.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
Avatar: "",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to insert user: %v", err)
|
|
}
|
|
|
|
// Test 1: Insert new manifest with core fields
|
|
manifest1 := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:abc123",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
ConfigDigest: "sha256:config123",
|
|
ConfigSize: 1024,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
id1, err := InsertManifest(db, manifest1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
if id1 == "" {
|
|
t.Error("Expected a manifest key")
|
|
}
|
|
|
|
// Insert annotations separately
|
|
annotations := map[string]string{
|
|
"org.opencontainers.image.title": "My App",
|
|
"org.opencontainers.image.description": "A cool application",
|
|
"org.opencontainers.image.source": "https://github.com/user/myapp",
|
|
"org.opencontainers.image.documentation": "https://docs.example.com",
|
|
"org.opencontainers.image.licenses": "MIT",
|
|
"io.atcr.icon": "https://example.com/icon.png",
|
|
"io.atcr.readme": "https://github.com/user/myapp/blob/main/README.md",
|
|
}
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", annotations)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert annotations: %v", err)
|
|
}
|
|
|
|
// Verify the manifest was inserted correctly
|
|
retrieved, err := GetManifest(db, manifest1.Digest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve manifest: %v", err)
|
|
}
|
|
if retrieved.Key != id1 {
|
|
t.Errorf("Expected key %s, got %s", id1, retrieved.Key)
|
|
}
|
|
|
|
// Verify annotations were inserted
|
|
retrievedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve annotations: %v", err)
|
|
}
|
|
if retrievedAnnotations["org.opencontainers.image.title"] != "My App" {
|
|
t.Errorf("Expected title 'My App', got '%s'", retrievedAnnotations["org.opencontainers.image.title"])
|
|
}
|
|
if retrievedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/main/README.md" {
|
|
t.Errorf("Expected readme_url, got '%s'", retrievedAnnotations["io.atcr.readme"])
|
|
}
|
|
|
|
// Test 2: Insert manifest with minimal fields (NULLs for annotations)
|
|
manifest2 := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "minimal",
|
|
Digest: "sha256:minimal123",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
id2, err := InsertManifest(db, manifest2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert minimal manifest: %v", err)
|
|
}
|
|
if id2 == "" {
|
|
t.Error("Expected non-zero manifest ID for minimal manifest")
|
|
}
|
|
|
|
_, err = GetManifest(db, manifest2.Digest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve minimal manifest: %v", err)
|
|
}
|
|
// Verify no annotations exist for minimal manifest
|
|
minimalAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "minimal")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get minimal annotations: %v", err)
|
|
}
|
|
if len(minimalAnnotations) != 0 {
|
|
t.Errorf("Expected no annotations for minimal manifest, got %d", len(minimalAnnotations))
|
|
}
|
|
|
|
// Test 3: Upsert existing manifest (same DID+repo+digest) - verify UPDATE path
|
|
manifest1Updated := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:abc123", // Same digest - should trigger UPDATE
|
|
HoldEndpoint: "did:web:hold2.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
ConfigDigest: "sha256:newconfig",
|
|
ConfigSize: 2048,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
id3, err := InsertManifest(db, manifest1Updated)
|
|
if err != nil {
|
|
t.Fatalf("Failed to upsert manifest: %v", err)
|
|
}
|
|
// Key should be the same as the original insert (UPDATE, not INSERT)
|
|
if id3 != id1 {
|
|
t.Errorf("Expected upsert to return the same key %s, got %s", id1, id3)
|
|
}
|
|
|
|
// Update annotations separately
|
|
updatedAnnotations := map[string]string{
|
|
"org.opencontainers.image.title": "My App v2",
|
|
"org.opencontainers.image.description": "An updated application",
|
|
"org.opencontainers.image.source": "https://github.com/user/myapp-v2",
|
|
"org.opencontainers.image.documentation": "https://v2.docs.example.com",
|
|
"org.opencontainers.image.licenses": "Apache-2.0",
|
|
"io.atcr.icon": "https://example.com/icon-v2.png",
|
|
"io.atcr.readme": "https://github.com/user/myapp/blob/v2/README.md",
|
|
}
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "myapp", updatedAnnotations)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update annotations: %v", err)
|
|
}
|
|
|
|
// Verify the manifest was updated
|
|
retrievedUpdated, err := GetManifest(db, manifest1.Digest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve updated manifest: %v", err)
|
|
}
|
|
if retrievedUpdated.HoldEndpoint != "did:web:hold2.example.com" {
|
|
t.Errorf("Expected updated hold_endpoint, got '%s'", retrievedUpdated.HoldEndpoint)
|
|
}
|
|
|
|
// Verify annotations were updated
|
|
retrievedUpdatedAnnotations, err := GetRepositoryAnnotations(db, testUser.DID, "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve updated annotations: %v", err)
|
|
}
|
|
if retrievedUpdatedAnnotations["org.opencontainers.image.title"] != "My App v2" {
|
|
t.Errorf("Expected updated title 'My App v2', got '%s'", retrievedUpdatedAnnotations["org.opencontainers.image.title"])
|
|
}
|
|
if retrievedUpdatedAnnotations["io.atcr.readme"] != "https://github.com/user/myapp/blob/v2/README.md" {
|
|
t.Errorf("Expected updated readme_url, got '%s'", retrievedUpdatedAnnotations["io.atcr.readme"])
|
|
}
|
|
|
|
// Test 4: Verify count - should have 2 manifests (not 3, because one was upserted)
|
|
digests, err := GetManifestDigestsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get manifest digests: %v", err)
|
|
}
|
|
if len(digests) != 2 {
|
|
t.Errorf("Expected 2 manifests after upsert, got %d", len(digests))
|
|
}
|
|
}
|
|
|
|
func TestUserManagement(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Test 1: Upsert new user
|
|
user1 := &User{
|
|
DID: "did:plc:alice123",
|
|
Handle: "alice.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
Avatar: "https://example.com/avatar.jpg",
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
err = UpsertUser(db, user1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to upsert new user: %v", err)
|
|
}
|
|
|
|
// Test 2: GetUserByDID - found
|
|
retrieved, err := GetUserByDID(db, user1.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get user by DID: %v", err)
|
|
}
|
|
if retrieved == nil {
|
|
t.Fatal("Expected user to be found, got nil")
|
|
}
|
|
if retrieved.Handle != "alice.bsky.social" {
|
|
t.Errorf("Expected handle 'alice.bsky.social', got '%s'", retrieved.Handle)
|
|
}
|
|
if retrieved.Avatar != "https://example.com/avatar.jpg" {
|
|
t.Errorf("Expected avatar URL, got '%s'", retrieved.Avatar)
|
|
}
|
|
|
|
// Test 3: GetUserByHandle - found
|
|
retrievedByHandle, err := GetUserByHandle(db, user1.Handle)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get user by handle: %v", err)
|
|
}
|
|
if retrievedByHandle == nil {
|
|
t.Fatal("Expected user to be found by handle, got nil")
|
|
}
|
|
if retrievedByHandle.DID != user1.DID {
|
|
t.Errorf("Expected DID '%s', got '%s'", user1.DID, retrievedByHandle.DID)
|
|
}
|
|
|
|
// Test 4: GetUserByDID - not found
|
|
notFound, err := GetUserByDID(db, "did:plc:nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for nonexistent user, got: %v", err)
|
|
}
|
|
if notFound != nil {
|
|
t.Error("Expected nil for nonexistent user")
|
|
}
|
|
|
|
// Test 5: GetUserByHandle - not found
|
|
notFoundByHandle, err := GetUserByHandle(db, "nonexistent.bsky.social")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for nonexistent handle, got: %v", err)
|
|
}
|
|
if notFoundByHandle != nil {
|
|
t.Error("Expected nil for nonexistent handle")
|
|
}
|
|
|
|
// Test 6: Upsert existing user (update)
|
|
user1.Handle = "alice-new.bsky.social" // Change handle
|
|
user1.Avatar = "" // Remove avatar
|
|
user1.LastSeen = time.Now().Add(1 * time.Hour)
|
|
|
|
err = UpsertUser(db, user1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to upsert existing user: %v", err)
|
|
}
|
|
|
|
// Verify update
|
|
updated, err := GetUserByDID(db, user1.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get updated user: %v", err)
|
|
}
|
|
if updated.Handle != "alice-new.bsky.social" {
|
|
t.Errorf("Expected updated handle 'alice-new.bsky.social', got '%s'", updated.Handle)
|
|
}
|
|
if updated.Avatar != "" {
|
|
t.Errorf("Expected empty avatar after update, got '%s'", updated.Avatar)
|
|
}
|
|
|
|
// Test 7: User with empty avatar (NULL)
|
|
user2 := &User{
|
|
DID: "did:plc:bob456",
|
|
Handle: "bob.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
Avatar: "", // Empty avatar
|
|
LastSeen: time.Now(),
|
|
}
|
|
|
|
err = UpsertUser(db, user2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to upsert user with empty avatar: %v", err)
|
|
}
|
|
|
|
retrieved2, err := GetUserByDID(db, user2.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get user with empty avatar: %v", err)
|
|
}
|
|
if retrieved2.Avatar != "" {
|
|
t.Errorf("Expected empty avatar, got '%s'", retrieved2.Avatar)
|
|
}
|
|
}
|
|
|
|
func TestManifestOperations(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setup: Create test user
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
|
|
// Insert test manifests
|
|
manifests := []*Manifest{
|
|
{
|
|
DID: testUser.DID,
|
|
Repository: "app1",
|
|
Digest: "sha256:aaa",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
},
|
|
{
|
|
DID: testUser.DID,
|
|
Repository: "app1",
|
|
Digest: "sha256:bbb",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
},
|
|
{
|
|
DID: testUser.DID,
|
|
Repository: "app2",
|
|
Digest: "sha256:ccc",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
},
|
|
}
|
|
|
|
for _, m := range manifests {
|
|
_, err := InsertManifest(db, m)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
}
|
|
|
|
// Insert annotations for test manifests
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "app1", map[string]string{
|
|
"org.opencontainers.image.title": "App 1",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert app1 annotations: %v", err)
|
|
}
|
|
err = UpsertRepositoryAnnotations(db, testUser.DID, "app2", map[string]string{
|
|
"org.opencontainers.image.title": "App 2",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert app2 annotations: %v", err)
|
|
}
|
|
|
|
// Test 1: GetManifest - found
|
|
retrieved, err := GetManifest(db, "sha256:aaa")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get manifest: %v", err)
|
|
}
|
|
if retrieved.Digest != "sha256:aaa" {
|
|
t.Errorf("Expected digest 'sha256:aaa', got '%s'", retrieved.Digest)
|
|
}
|
|
|
|
// Test 2: GetManifest - not found
|
|
notFound, err := GetManifest(db, "sha256:nonexistent")
|
|
if err == nil {
|
|
t.Error("Expected error for nonexistent manifest")
|
|
}
|
|
if notFound != nil {
|
|
t.Error("Expected nil for nonexistent manifest")
|
|
}
|
|
|
|
// Test 3: GetManifestDigestsForDID - multiple manifests
|
|
digests, err := GetManifestDigestsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get manifest digests: %v", err)
|
|
}
|
|
if len(digests) != 3 {
|
|
t.Errorf("Expected 3 manifests, got %d", len(digests))
|
|
}
|
|
|
|
// Test 4: GetManifestDigestsForDID - no manifests
|
|
noDigests, err := GetManifestDigestsForDID(db, "did:plc:nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for user with no manifests, got: %v", err)
|
|
}
|
|
if len(noDigests) != 0 {
|
|
t.Errorf("Expected 0 manifests for nonexistent user, got %d", len(noDigests))
|
|
}
|
|
|
|
// Test 5: DeleteManifestsNotInList - keep some, delete others
|
|
keepDigests := []string{"sha256:aaa", "sha256:ccc"}
|
|
err = DeleteManifestsNotInList(db, testUser.DID, keepDigests)
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete manifests not in list: %v", err)
|
|
}
|
|
|
|
// Verify only sha256:aaa and sha256:ccc remain
|
|
remaining, err := GetManifestDigestsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get remaining manifests: %v", err)
|
|
}
|
|
if len(remaining) != 2 {
|
|
t.Errorf("Expected 2 remaining manifests, got %d", len(remaining))
|
|
}
|
|
|
|
// Verify sha256:bbb was deleted
|
|
deleted, err := GetManifest(db, "sha256:bbb")
|
|
if err == nil {
|
|
t.Error("Expected error for deleted manifest")
|
|
}
|
|
if deleted != nil {
|
|
t.Error("Expected nil for deleted manifest")
|
|
}
|
|
|
|
// Test 6: DeleteManifestsNotInList - empty list (delete all)
|
|
err = DeleteManifestsNotInList(db, testUser.DID, []string{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete all manifests: %v", err)
|
|
}
|
|
|
|
allGone, err := GetManifestDigestsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get manifests after delete all: %v", err)
|
|
}
|
|
if len(allGone) != 0 {
|
|
t.Errorf("Expected 0 manifests after delete all, got %d", len(allGone))
|
|
}
|
|
|
|
// Test 7: DeleteManifest - specific deletion (re-insert for this test)
|
|
manifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "app3",
|
|
Digest: "sha256:ddd",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
_, err = InsertManifest(db, manifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest for delete test: %v", err)
|
|
}
|
|
|
|
// Delete by DID+repo+digest
|
|
err = DeleteManifest(db, testUser.DID, "app3", "sha256:ddd")
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete manifest: %v", err)
|
|
}
|
|
|
|
// Verify deletion
|
|
afterDelete, err := GetManifest(db, "sha256:ddd")
|
|
if err == nil {
|
|
t.Error("Expected error for deleted manifest")
|
|
}
|
|
if afterDelete != nil {
|
|
t.Error("Expected nil after deletion")
|
|
}
|
|
}
|
|
|
|
func TestIsManifestTagged(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setup: Create test user
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
|
|
// Insert manifest
|
|
manifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:abc123",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
_, err = InsertManifest(db, manifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
|
|
// Test 1: Manifest without tags
|
|
tagged, err := IsManifestTagged(db, testUser.DID, "myapp", "sha256:abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if manifest is tagged: %v", err)
|
|
}
|
|
if tagged {
|
|
t.Error("Expected manifest to not be tagged")
|
|
}
|
|
|
|
// Test 2: Add a tag
|
|
tag := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:abc123",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, tag)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert tag: %v", err)
|
|
}
|
|
|
|
// Test 3: Manifest with tag
|
|
taggedNow, err := IsManifestTagged(db, testUser.DID, "myapp", "sha256:abc123")
|
|
if err != nil {
|
|
t.Fatalf("Failed to check if manifest is tagged: %v", err)
|
|
}
|
|
if !taggedNow {
|
|
t.Error("Expected manifest to be tagged")
|
|
}
|
|
}
|
|
|
|
func TestTagOperations(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setup: Create test user and manifests
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
|
|
manifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:abc123",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
_, err = InsertManifest(db, manifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
|
|
// Test 1: UpsertTag - insert new tag
|
|
tag1 := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:abc123",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, tag1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to upsert tag: %v", err)
|
|
}
|
|
|
|
// Test 2: GetTagsForDID - should have 1 tag
|
|
tags, err := GetTagsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get tags: %v", err)
|
|
}
|
|
if len(tags) != 1 {
|
|
t.Errorf("Expected 1 tag, got %d", len(tags))
|
|
}
|
|
if tags[0].Repository != "myapp" || tags[0].Tag != "latest" {
|
|
t.Errorf("Expected myapp:latest, got %s:%s", tags[0].Repository, tags[0].Tag)
|
|
}
|
|
|
|
// Test 3: UpsertTag - update existing tag (point to new digest)
|
|
tag1Updated := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "latest", // Same tag
|
|
Digest: "sha256:new456",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, tag1Updated)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update tag: %v", err)
|
|
}
|
|
|
|
// Verify update - should still have 1 tag but with new digest
|
|
tagsAfterUpdate, err := GetTagsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get tags after update: %v", err)
|
|
}
|
|
if len(tagsAfterUpdate) != 1 {
|
|
t.Errorf("Expected 1 tag after update, got %d", len(tagsAfterUpdate))
|
|
}
|
|
if tagsAfterUpdate[0].Tag != "latest" {
|
|
t.Errorf("Expected tag 'latest', got '%s'", tagsAfterUpdate[0].Tag)
|
|
}
|
|
|
|
// Test 4: Add more tags
|
|
tag2 := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "v1.0.0",
|
|
Digest: "sha256:abc123",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
tag3 := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "otherapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:xyz789",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, tag2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert tag2: %v", err)
|
|
}
|
|
err = UpsertTag(db, tag3)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert tag3: %v", err)
|
|
}
|
|
|
|
// Verify count
|
|
allTags, err := GetTagsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get all tags: %v", err)
|
|
}
|
|
if len(allTags) != 3 {
|
|
t.Errorf("Expected 3 tags, got %d", len(allTags))
|
|
}
|
|
|
|
// Test 5: DeleteTagsNotInList - keep some, delete others
|
|
keepTags := []struct{ Repository, Tag string }{
|
|
{Repository: "myapp", Tag: "latest"},
|
|
{Repository: "otherapp", Tag: "latest"},
|
|
}
|
|
err = DeleteTagsNotInList(db, testUser.DID, keepTags)
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete tags not in list: %v", err)
|
|
}
|
|
|
|
// Verify v1.0.0 was deleted
|
|
remaining, err := GetTagsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get remaining tags: %v", err)
|
|
}
|
|
if len(remaining) != 2 {
|
|
t.Errorf("Expected 2 remaining tags, got %d", len(remaining))
|
|
}
|
|
|
|
// Test 6: DeleteTag - specific deletion
|
|
err = DeleteTag(db, testUser.DID, "myapp", "latest")
|
|
if err != nil {
|
|
t.Fatalf("Failed to delete tag: %v", err)
|
|
}
|
|
|
|
// Verify deletion
|
|
afterDelete, err := GetTagsForDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get tags after delete: %v", err)
|
|
}
|
|
if len(afterDelete) != 1 {
|
|
t.Errorf("Expected 1 tag after delete, got %d", len(afterDelete))
|
|
}
|
|
if afterDelete[0].Repository != "otherapp" {
|
|
t.Errorf("Wrong tag remained: %s:%s", afterDelete[0].Repository, afterDelete[0].Tag)
|
|
}
|
|
|
|
// Test 7: GetTagsForDID - no tags
|
|
noTags, err := GetTagsForDID(db, "did:plc:nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("Expected no error for user with no tags, got: %v", err)
|
|
}
|
|
if len(noTags) != 0 {
|
|
t.Errorf("Expected 0 tags for nonexistent user, got %d", len(noTags))
|
|
}
|
|
}
|
|
|
|
func TestGetTagsWithPlatforms(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setup: Create test user
|
|
testUser := &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
|
|
// Register the test hold as public so the hold-access filter allows it
|
|
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
|
|
HoldDID: "did:web:hold.example.com",
|
|
OwnerDID: "did:plc:holdowner",
|
|
Public: true,
|
|
}); err != nil {
|
|
t.Fatalf("Failed to insert captain record: %v", err)
|
|
}
|
|
|
|
// Test 1: Single-arch manifest (no platform info)
|
|
singleArchManifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:single",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
manifestID1, err := InsertManifest(db, singleArchManifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert single-arch manifest: %v", err)
|
|
}
|
|
|
|
singleTag := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:single",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, singleTag)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert single-arch tag: %v", err)
|
|
}
|
|
|
|
tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0, "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get tags with platforms: %v", err)
|
|
}
|
|
if len(tagsWithPlatforms) != 1 {
|
|
t.Fatalf("Expected 1 tag, got %d", len(tagsWithPlatforms))
|
|
}
|
|
if tagsWithPlatforms[0].IsMultiArch {
|
|
t.Error("Expected single-arch tag to not be multi-arch")
|
|
}
|
|
if len(tagsWithPlatforms[0].Platforms) != 0 {
|
|
t.Errorf("Expected 0 platforms for single-arch, got %d", len(tagsWithPlatforms[0].Platforms))
|
|
}
|
|
|
|
// Test 2: Multi-arch manifest (manifest list with platform info)
|
|
multiArchManifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "multiapp",
|
|
Digest: "sha256:multi",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.index.v1+json", // Manifest list
|
|
CreatedAt: time.Now(),
|
|
}
|
|
manifestID2, err := InsertManifest(db, multiArchManifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert multi-arch manifest: %v", err)
|
|
}
|
|
|
|
// Add manifest references with platform info
|
|
ref1 := &ManifestReference{
|
|
ManifestKey: manifestID2,
|
|
Digest: "sha256:amd64",
|
|
Size: 1000,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformOS: "linux",
|
|
PlatformArchitecture: "amd64",
|
|
ReferenceIndex: 0,
|
|
}
|
|
ref2 := &ManifestReference{
|
|
ManifestKey: manifestID2,
|
|
Digest: "sha256:arm64",
|
|
Size: 1000,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformOS: "linux",
|
|
PlatformArchitecture: "arm64",
|
|
ReferenceIndex: 1,
|
|
}
|
|
err = InsertManifestReference(db, ref1)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest reference 1: %v", err)
|
|
}
|
|
err = InsertManifestReference(db, ref2)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest reference 2: %v", err)
|
|
}
|
|
|
|
multiTag := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "multiapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:multi",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
err = UpsertTag(db, multiTag)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert multi-arch tag: %v", err)
|
|
}
|
|
|
|
multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0, "")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get multi-arch tags with platforms: %v", err)
|
|
}
|
|
if len(multiTagsWithPlatforms) != 1 {
|
|
t.Fatalf("Expected 1 tag, got %d", len(multiTagsWithPlatforms))
|
|
}
|
|
if !multiTagsWithPlatforms[0].IsMultiArch {
|
|
t.Error("Expected multi-arch tag to be marked as multi-arch")
|
|
}
|
|
if len(multiTagsWithPlatforms[0].Platforms) != 2 {
|
|
t.Errorf("Expected 2 platforms for multi-arch, got %d", len(multiTagsWithPlatforms[0].Platforms))
|
|
}
|
|
|
|
// Verify platform details
|
|
platforms := multiTagsWithPlatforms[0].Platforms
|
|
if platforms[0].OS != "linux" || platforms[0].Architecture != "amd64" {
|
|
t.Errorf("Expected linux/amd64, got %s/%s", platforms[0].OS, platforms[0].Architecture)
|
|
}
|
|
if platforms[1].OS != "linux" || platforms[1].Architecture != "arm64" {
|
|
t.Errorf("Expected linux/arm64, got %s/%s", platforms[1].OS, platforms[1].Architecture)
|
|
}
|
|
|
|
// Don't use manifestID1 since it's not accessed after assignment
|
|
_ = manifestID1
|
|
}
|
|
|
|
func TestUserRegistryDomain(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
user := &User{
|
|
DID: "did:plc:domainpref",
|
|
Handle: "pref.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("Failed to upsert user: %v", err)
|
|
}
|
|
|
|
// Default is empty string (NULL-safe scan).
|
|
got, err := GetUserByDID(db, user.DID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserByDID failed: %v", err)
|
|
}
|
|
if got.RegistryDomain != "" {
|
|
t.Errorf("Expected empty registry domain by default, got %q", got.RegistryDomain)
|
|
}
|
|
|
|
// Update and confirm it round-trips through both lookups.
|
|
if err := UpdateUserRegistryDomain(db, user.DID, "buoy.cr"); err != nil {
|
|
t.Fatalf("UpdateUserRegistryDomain failed: %v", err)
|
|
}
|
|
byDID, err := GetUserByDID(db, user.DID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserByDID failed: %v", err)
|
|
}
|
|
if byDID.RegistryDomain != "buoy.cr" {
|
|
t.Errorf("GetUserByDID: expected 'buoy.cr', got %q", byDID.RegistryDomain)
|
|
}
|
|
byHandle, err := GetUserByHandle(db, user.Handle)
|
|
if err != nil {
|
|
t.Fatalf("GetUserByHandle failed: %v", err)
|
|
}
|
|
if byHandle.RegistryDomain != "buoy.cr" {
|
|
t.Errorf("GetUserByHandle: expected 'buoy.cr', got %q", byHandle.RegistryDomain)
|
|
}
|
|
|
|
// Clearing the preference propagates back to empty.
|
|
if err := UpdateUserRegistryDomain(db, user.DID, ""); err != nil {
|
|
t.Fatalf("UpdateUserRegistryDomain (clear) failed: %v", err)
|
|
}
|
|
cleared, err := GetUserByDID(db, user.DID)
|
|
if err != nil {
|
|
t.Fatalf("GetUserByDID failed: %v", err)
|
|
}
|
|
if cleared.RegistryDomain != "" {
|
|
t.Errorf("Expected empty registry domain after clear, got %q", cleared.RegistryDomain)
|
|
}
|
|
}
|
|
|
|
func TestUpdateUserHandle(t *testing.T) {
|
|
// Create in-memory test database
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Setup: Create test user
|
|
testUser := &User{
|
|
DID: "did:plc:alice123",
|
|
Handle: "alice.bsky.social",
|
|
PDSEndpoint: "https://bsky.social",
|
|
Avatar: "https://example.com/avatar.jpg",
|
|
LastSeen: time.Now(),
|
|
}
|
|
err = UpsertUser(db, testUser)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test user: %v", err)
|
|
}
|
|
|
|
// Test 1: Update handle for existing user
|
|
newHandle := "alice-new.bsky.social"
|
|
err = UpdateUserHandle(db, testUser.DID, newHandle)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update user handle: %v", err)
|
|
}
|
|
|
|
// Verify handle was updated
|
|
retrieved, err := GetUserByDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get user after handle update: %v", err)
|
|
}
|
|
if retrieved == nil {
|
|
t.Fatal("Expected user to be found, got nil")
|
|
}
|
|
if retrieved.Handle != newHandle {
|
|
t.Errorf("Expected handle '%s', got '%s'", newHandle, retrieved.Handle)
|
|
}
|
|
|
|
// Verify other fields unchanged
|
|
if retrieved.DID != testUser.DID {
|
|
t.Errorf("DID changed unexpectedly: %s -> %s", testUser.DID, retrieved.DID)
|
|
}
|
|
if retrieved.PDSEndpoint != testUser.PDSEndpoint {
|
|
t.Errorf("PDS endpoint changed unexpectedly")
|
|
}
|
|
if retrieved.Avatar != testUser.Avatar {
|
|
t.Errorf("Avatar changed unexpectedly")
|
|
}
|
|
|
|
// Test 2: Update handle for non-existent user (should not error, but no rows affected)
|
|
err = UpdateUserHandle(db, "did:plc:nonexistent", "new.handle.social")
|
|
if err != nil {
|
|
t.Errorf("Expected no error for non-existent user, got: %v", err)
|
|
}
|
|
|
|
// Test 3: Update handle multiple times
|
|
handles := []string{"alice1.bsky.social", "alice2.bsky.social", "alice3.bsky.social"}
|
|
for _, handle := range handles {
|
|
err = UpdateUserHandle(db, testUser.DID, handle)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update handle to '%s': %v", handle, err)
|
|
}
|
|
|
|
retrieved, err = GetUserByDID(db, testUser.DID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to retrieve user: %v", err)
|
|
}
|
|
if retrieved.Handle != handle {
|
|
t.Errorf("Expected handle '%s', got '%s'", handle, retrieved.Handle)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEscapeLikePattern tests the SQL LIKE pattern escaping function
|
|
func TestEscapeLikePattern(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "plain text",
|
|
input: "hello",
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "with percent wildcard",
|
|
input: "hello%world",
|
|
expected: "hello\\%world",
|
|
},
|
|
{
|
|
name: "with underscore wildcard",
|
|
input: "hello_world",
|
|
expected: "hello\\_world",
|
|
},
|
|
{
|
|
name: "with backslash",
|
|
input: "hello\\world",
|
|
expected: "hello\\\\world",
|
|
},
|
|
{
|
|
name: "with null byte",
|
|
input: "test\x00null",
|
|
expected: "testnull",
|
|
},
|
|
{
|
|
name: "with control characters",
|
|
input: "test\x01\x02control",
|
|
expected: "testcontrol",
|
|
},
|
|
{
|
|
name: "keep tabs and newlines",
|
|
input: "test\t\n\rwhitespace",
|
|
expected: "test\t\n\rwhitespace",
|
|
},
|
|
{
|
|
name: "with leading/trailing spaces",
|
|
input: " padded ",
|
|
expected: "padded",
|
|
},
|
|
{
|
|
name: "multiple wildcards",
|
|
input: "test%_value\\here",
|
|
expected: "test\\%\\_value\\\\here",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "only spaces",
|
|
input: " ",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := escapeLikePattern(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("escapeLikePattern(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseTimestamp tests the timestamp parsing function with multiple formats
|
|
func TestParseTimestamp(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
shouldErr bool
|
|
}{
|
|
{
|
|
name: "RFC3339",
|
|
input: "2024-01-01T12:00:00Z",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "RFC3339Nano",
|
|
input: "2024-01-01T12:00:00.123456789Z",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "SQLite format",
|
|
input: "2024-01-01 12:00:00",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "SQLite with nanos",
|
|
input: "2024-01-01 12:00:00.123456789",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "SQLite with timezone",
|
|
input: "2024-01-01 12:00:00.123456789-07:00",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "RFC3339 with timezone",
|
|
input: "2024-01-01T12:00:00-07:00",
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "invalid format",
|
|
input: "not-a-date",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
shouldErr: true,
|
|
},
|
|
{
|
|
name: "partial date",
|
|
input: "2024-01-01",
|
|
shouldErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := parseTimestamp(tt.input)
|
|
if tt.shouldErr {
|
|
if err == nil {
|
|
t.Errorf("parseTimestamp(%q) expected error, got nil (result: %v)", tt.input, result)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("parseTimestamp(%q) unexpected error: %v", tt.input, err)
|
|
}
|
|
if result.IsZero() {
|
|
t.Errorf("parseTimestamp(%q) returned zero time", tt.input)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeleteUserData(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Create test user with related data
|
|
testUser := &User{
|
|
DID: "did:plc:deleteme",
|
|
Handle: "deleteme.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("Failed to insert user: %v", err)
|
|
}
|
|
|
|
// Add manifest
|
|
manifest := &Manifest{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Digest: "sha256:abc123",
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
manifestID, err := InsertManifest(db, manifest)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest: %v", err)
|
|
}
|
|
|
|
// Add layer
|
|
layer := &Layer{
|
|
ManifestKey: manifestID,
|
|
LayerIndex: 0,
|
|
Digest: "sha256:layer1",
|
|
Size: 1000,
|
|
MediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
}
|
|
if err := InsertLayer(db, layer); err != nil {
|
|
t.Fatalf("Failed to insert layer: %v", err)
|
|
}
|
|
|
|
// Add tag
|
|
tag := &Tag{
|
|
DID: testUser.DID,
|
|
Repository: "myapp",
|
|
Tag: "latest",
|
|
Digest: "sha256:abc123",
|
|
CreatedAt: time.Now(),
|
|
}
|
|
if err := UpsertTag(db, tag); err != nil {
|
|
t.Fatalf("Failed to insert tag: %v", err)
|
|
}
|
|
|
|
// Add annotations
|
|
if err := UpsertRepositoryAnnotations(db, testUser.DID, "myapp", map[string]string{
|
|
"org.opencontainers.image.title": "My App",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to insert annotations: %v", err)
|
|
}
|
|
|
|
// Verify data exists
|
|
var count int
|
|
db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ?`, testUser.DID).Scan(&count)
|
|
if count != 1 {
|
|
t.Fatalf("Expected 1 manifest, got %d", count)
|
|
}
|
|
db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ?`, testUser.DID).Scan(&count)
|
|
if count != 1 {
|
|
t.Fatalf("Expected 1 tag, got %d", count)
|
|
}
|
|
db.QueryRow(`SELECT COUNT(*) FROM layers WHERE manifest_key = ?`, manifestID).Scan(&count)
|
|
if count != 1 {
|
|
t.Fatalf("Expected 1 layer, got %d", count)
|
|
}
|
|
|
|
// Delete user data
|
|
if _, err := DeleteUserData(db, testUser.DID); err != nil {
|
|
t.Fatalf("Failed to delete user data: %v", err)
|
|
}
|
|
|
|
// Verify all data was cascade deleted
|
|
db.QueryRow(`SELECT COUNT(*) FROM users WHERE did = ?`, testUser.DID).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("Expected 0 users, got %d", count)
|
|
}
|
|
db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ?`, testUser.DID).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("Expected 0 manifests after cascade delete, got %d", count)
|
|
}
|
|
db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ?`, testUser.DID).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("Expected 0 tags after cascade delete, got %d", count)
|
|
}
|
|
db.QueryRow(`SELECT COUNT(*) FROM layers WHERE manifest_key = ?`, manifestID).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("Expected 0 layers after cascade delete, got %d", count)
|
|
}
|
|
|
|
// Test idempotency - deleting non-existent user should not error
|
|
if deleted, err := DeleteUserData(db, testUser.DID); err != nil {
|
|
t.Errorf("Deleting non-existent user should not error, got: %v", err)
|
|
} else if deleted {
|
|
t.Errorf("Deleting non-existent user should return false, got true")
|
|
}
|
|
}
|
|
|
|
func TestIsManifestReferenced(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Insert test user
|
|
if err := UpsertUser(db, &User{
|
|
DID: "did:plc:test123",
|
|
Handle: "testuser.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("Failed to insert user: %v", err)
|
|
}
|
|
|
|
// Insert a manifest list
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifests (manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, ManifestKey("did:plc:test123", "myapp", "sha256:indexabc"),
|
|
"did:plc:test123", "myapp", "sha256:indexabc", "did:web:hold.example.com", 2,
|
|
"application/vnd.oci.image.index.v1+json", time.Now())
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest list: %v", err)
|
|
}
|
|
|
|
var manifestKey string
|
|
db.QueryRow(`SELECT manifest_key FROM manifests WHERE digest = ?`, "sha256:indexabc").Scan(&manifestKey)
|
|
|
|
// Insert a child manifest reference
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifest_references (manifest_key, digest, media_type, size, platform_architecture, platform_os, reference_index)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, manifestKey, "sha256:childdef", "application/vnd.oci.image.manifest.v1+json", 1000, "amd64", "linux", 0)
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest reference: %v", err)
|
|
}
|
|
|
|
// Test 1: child digest should be referenced
|
|
referenced, err := IsManifestReferenced(db, "did:plc:test123", "sha256:childdef")
|
|
if err != nil {
|
|
t.Fatalf("IsManifestReferenced error: %v", err)
|
|
}
|
|
if !referenced {
|
|
t.Error("Expected sha256:childdef to be referenced as a manifest list child")
|
|
}
|
|
|
|
// Test 2: unrelated digest should NOT be referenced
|
|
referenced, err = IsManifestReferenced(db, "did:plc:test123", "sha256:unrelated")
|
|
if err != nil {
|
|
t.Fatalf("IsManifestReferenced error: %v", err)
|
|
}
|
|
if referenced {
|
|
t.Error("Expected sha256:unrelated to NOT be referenced")
|
|
}
|
|
|
|
// Test 3: same digest but different user should NOT be referenced
|
|
referenced, err = IsManifestReferenced(db, "did:plc:otheruser", "sha256:childdef")
|
|
if err != nil {
|
|
t.Fatalf("IsManifestReferenced error: %v", err)
|
|
}
|
|
if referenced {
|
|
t.Error("Expected sha256:childdef to NOT be referenced for different user")
|
|
}
|
|
}
|
|
|
|
func TestGetAllUntaggedManifestDigests(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to init database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
did := "did:plc:test123"
|
|
repo := "myapp"
|
|
now := time.Now()
|
|
|
|
if err := UpsertUser(db, &User{
|
|
DID: did,
|
|
Handle: "test.bsky.social",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: now,
|
|
}); err != nil {
|
|
t.Fatalf("Failed to insert user: %v", err)
|
|
}
|
|
|
|
indexType := "application/vnd.oci.image.index.v1+json"
|
|
manifestType := "application/vnd.oci.image.manifest.v1+json"
|
|
hold := "did:web:hold.example.com"
|
|
|
|
insertManifest := func(t *testing.T, digest, mediaType string) string {
|
|
t.Helper()
|
|
id, err := InsertManifest(db, &Manifest{
|
|
DID: did, Repository: repo, Digest: digest,
|
|
HoldEndpoint: hold, SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert manifest %s: %v", digest, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
insertRef := func(t *testing.T, parentKey string, childDigest string, idx int) {
|
|
t.Helper()
|
|
err := InsertManifestReference(db, &ManifestReference{
|
|
ManifestKey: parentKey,
|
|
Digest: childDigest,
|
|
Size: 1000,
|
|
MediaType: manifestType,
|
|
PlatformArchitecture: "amd64",
|
|
PlatformOS: "linux",
|
|
ReferenceIndex: idx,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Failed to insert reference: %v", err)
|
|
}
|
|
}
|
|
|
|
insertTag := func(t *testing.T, digest, tag string) {
|
|
t.Helper()
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did, Repository: repo, Tag: tag,
|
|
Digest: digest, CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("Failed to insert tag: %v", err)
|
|
}
|
|
}
|
|
|
|
// Setup scenario:
|
|
//
|
|
// TAGGED index "sha256:tagged-index" -> tag "v1"
|
|
// children: sha256:tagged-child-amd64, sha256:shared-child-arm64
|
|
//
|
|
// UNTAGGED index "sha256:untagged-index" (no tag)
|
|
// children: sha256:untagged-child-amd64, sha256:shared-child-arm64
|
|
//
|
|
// UNTAGGED orphan single-arch "sha256:orphan-single" (no tag, no parent)
|
|
//
|
|
// TAGGED single-arch "sha256:tagged-single" -> tag "latest"
|
|
|
|
// Tagged index + its children
|
|
taggedIndexID := insertManifest(t, "sha256:tagged-index", indexType)
|
|
insertManifest(t, "sha256:tagged-child-amd64", manifestType)
|
|
insertManifest(t, "sha256:shared-child-arm64", manifestType)
|
|
insertRef(t, taggedIndexID, "sha256:tagged-child-amd64", 0)
|
|
insertRef(t, taggedIndexID, "sha256:shared-child-arm64", 1)
|
|
insertTag(t, "sha256:tagged-index", "v1")
|
|
|
|
// Untagged index + its children
|
|
untaggedIndexID := insertManifest(t, "sha256:untagged-index", indexType)
|
|
insertManifest(t, "sha256:untagged-child-amd64", manifestType)
|
|
// sha256:shared-child-arm64 already inserted, just add the reference
|
|
insertRef(t, untaggedIndexID, "sha256:untagged-child-amd64", 0)
|
|
insertRef(t, untaggedIndexID, "sha256:shared-child-arm64", 1)
|
|
|
|
// Orphan single-arch (no parent, no tag)
|
|
insertManifest(t, "sha256:orphan-single", manifestType)
|
|
|
|
// Tagged single-arch
|
|
insertManifest(t, "sha256:tagged-single", manifestType)
|
|
insertTag(t, "sha256:tagged-single", "latest")
|
|
|
|
// Run the query
|
|
digests, err := GetAllUntaggedManifestDigests(db, did, repo)
|
|
if err != nil {
|
|
t.Fatalf("GetAllUntaggedManifestDigests error: %v", err)
|
|
}
|
|
|
|
// Build sets for easy checking
|
|
digestSet := map[string]bool{}
|
|
for _, d := range digests {
|
|
digestSet[d] = true
|
|
}
|
|
|
|
// Should include: untagged index, its exclusive child, and the orphan single
|
|
if !digestSet["sha256:untagged-index"] {
|
|
t.Error("Expected untagged-index to be included")
|
|
}
|
|
if !digestSet["sha256:untagged-child-amd64"] {
|
|
t.Error("Expected untagged-child-amd64 to be included")
|
|
}
|
|
if !digestSet["sha256:orphan-single"] {
|
|
t.Error("Expected orphan-single to be included")
|
|
}
|
|
|
|
// Should NOT include: tagged index, tagged children, shared child (still referenced by tagged index), tagged single
|
|
if digestSet["sha256:tagged-index"] {
|
|
t.Error("Expected tagged-index to NOT be included")
|
|
}
|
|
if digestSet["sha256:tagged-child-amd64"] {
|
|
t.Error("Expected tagged-child-amd64 to NOT be included")
|
|
}
|
|
if digestSet["sha256:shared-child-arm64"] {
|
|
t.Error("Expected shared-child-arm64 to NOT be included (still referenced by tagged index)")
|
|
}
|
|
if digestSet["sha256:tagged-single"] {
|
|
t.Error("Expected tagged-single to NOT be included")
|
|
}
|
|
|
|
// Verify ordering: children should come before their parent index
|
|
childIdx := -1
|
|
parentIdx := -1
|
|
for i, d := range digests {
|
|
if d == "sha256:untagged-child-amd64" {
|
|
childIdx = i
|
|
}
|
|
if d == "sha256:untagged-index" {
|
|
parentIdx = i
|
|
}
|
|
}
|
|
if childIdx >= 0 && parentIdx >= 0 && childIdx > parentIdx {
|
|
t.Errorf("Expected children before parents: child at index %d, parent at index %d", childIdx, parentIdx)
|
|
}
|
|
|
|
// Verify total count: untagged-child-amd64, orphan-single, untagged-index = 3
|
|
if len(digests) != 3 {
|
|
t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests)
|
|
}
|
|
}
|
|
|
|
// TestGetUserRepositories_HoldAccessFilter verifies that repositories whose
|
|
// manifests live on inaccessible holds are hidden from viewers without access.
|
|
func TestGetUserRepositories_HoldAccessFilter(t *testing.T) {
|
|
db, err := InitDB("file:TestGetUserRepositories_HoldAccessFilter?mode=memory&cache=shared", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
testUser := &User{DID: "did:plc:alice", Handle: "alice.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
|
|
if err := UpsertUser(db, testUser); err != nil {
|
|
t.Fatalf("upsert user: %v", err)
|
|
}
|
|
|
|
// Public hold and a private invite-only hold
|
|
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
|
|
HoldDID: "did:web:public.example", OwnerDID: "did:plc:holdowner", Public: true,
|
|
}); err != nil {
|
|
t.Fatalf("seed public captain: %v", err)
|
|
}
|
|
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
|
|
HoldDID: "did:web:private.example", OwnerDID: "did:plc:holdowner", Public: false, AllowAllCrew: false,
|
|
}); err != nil {
|
|
t.Fatalf("seed private captain: %v", err)
|
|
}
|
|
|
|
// Two repos: one on the public hold, one on the private hold
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: testUser.DID, Repository: "publicrepo", Digest: "sha256:pub",
|
|
HoldEndpoint: "did:web:public.example", SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("insert public manifest: %v", err)
|
|
}
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: testUser.DID, Repository: "privaterepo", Digest: "sha256:priv",
|
|
HoldEndpoint: "did:web:private.example", SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("insert private manifest: %v", err)
|
|
}
|
|
|
|
// Anonymous viewer should see only the publicrepo
|
|
repos, err := GetUserRepositories(db, testUser.DID, "")
|
|
if err != nil {
|
|
t.Fatalf("GetUserRepositories anon: %v", err)
|
|
}
|
|
if len(repos) != 1 || repos[0].Name != "publicrepo" {
|
|
t.Errorf("anon viewer: expected [publicrepo], got %v", repos)
|
|
}
|
|
|
|
// Make the private-hold owner a crew member and re-query as them
|
|
if err := UpsertCrewMember(db, &CrewMember{
|
|
HoldDID: "did:web:private.example", MemberDID: "did:plc:crewdave", Rkey: "rk1",
|
|
}); err != nil {
|
|
t.Fatalf("upsert crew: %v", err)
|
|
}
|
|
|
|
repos, err = GetUserRepositories(db, testUser.DID, "did:plc:crewdave")
|
|
if err != nil {
|
|
t.Fatalf("GetUserRepositories crew: %v", err)
|
|
}
|
|
if len(repos) != 2 {
|
|
t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos)
|
|
}
|
|
}
|
|
|
|
// TestGetUserRepositories_BulkGrouping verifies that the bulk-fetch
|
|
// implementation correctly groups tags, manifests, annotations, and repo-page
|
|
// avatars per repository — and that ordering (last_push DESC for repos,
|
|
// created_at DESC for tags/manifests within a repo) is preserved.
|
|
//
|
|
// Regression guard for the previous N+1 implementation, which issued one
|
|
// query per repo and per relation.
|
|
func TestGetUserRepositories_BulkGrouping(t *testing.T) {
|
|
db, err := InitDB("file:TestGetUserRepositories_BulkGrouping?mode=memory&cache=shared", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
user := &User{DID: "did:plc:owner", Handle: "owner.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("upsert user: %v", err)
|
|
}
|
|
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
|
|
HoldDID: "did:web:hold.example", OwnerDID: "did:plc:holdowner", Public: true,
|
|
}); err != nil {
|
|
t.Fatalf("seed captain: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
mediaType := "application/vnd.oci.image.manifest.v1+json"
|
|
|
|
// repoA: two manifests (oldest then newer) and two tags. last_push = now+10s.
|
|
manifestA1, err := InsertManifest(db, &Manifest{
|
|
DID: user.DID, Repository: "repoA", Digest: "sha256:a1",
|
|
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("insert manifest a1: %v", err)
|
|
}
|
|
manifestA2, err := InsertManifest(db, &Manifest{
|
|
DID: user.DID, Repository: "repoA", Digest: "sha256:a2",
|
|
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now.Add(5 * time.Second),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("insert manifest a2: %v", err)
|
|
}
|
|
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v1", Digest: "sha256:a1", CreatedAt: now.Add(8 * time.Second)}); err != nil {
|
|
t.Fatalf("upsert tag v1: %v", err)
|
|
}
|
|
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v2", Digest: "sha256:a2", CreatedAt: now.Add(10 * time.Second)}); err != nil {
|
|
t.Fatalf("upsert tag v2: %v", err)
|
|
}
|
|
|
|
// repoB: one manifest, one tag. last_push = now+1s (older than repoA → repoA sorts first).
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: user.DID, Repository: "repoB", Digest: "sha256:b1",
|
|
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now.Add(1 * time.Second),
|
|
}); err != nil {
|
|
t.Fatalf("insert manifest b1: %v", err)
|
|
}
|
|
if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoB", Tag: "latest", Digest: "sha256:b1", CreatedAt: now.Add(1 * time.Second)}); err != nil {
|
|
t.Fatalf("upsert tag b latest: %v", err)
|
|
}
|
|
|
|
// Annotations only on repoA, plus a repo-page avatar on repoB to exercise the icon override.
|
|
if err := UpsertRepositoryAnnotations(db, user.DID, "repoA", map[string]string{
|
|
"org.opencontainers.image.title": "Repo A Title",
|
|
"org.opencontainers.image.description": "alpha",
|
|
"io.atcr.icon": "https://example.com/a.png",
|
|
}); err != nil {
|
|
t.Fatalf("upsert annotations: %v", err)
|
|
}
|
|
if err := UpsertRepoPage(db, user.DID, "repoB", "", "bafyrepob", false, now, now); err != nil {
|
|
t.Fatalf("upsert repo page: %v", err)
|
|
}
|
|
|
|
repos, err := GetUserRepositories(db, user.DID, "")
|
|
if err != nil {
|
|
t.Fatalf("GetUserRepositories: %v", err)
|
|
}
|
|
|
|
// Order: repoA first (newer last_push), then repoB.
|
|
if len(repos) != 2 {
|
|
t.Fatalf("expected 2 repos, got %d: %#v", len(repos), repos)
|
|
}
|
|
if repos[0].Name != "repoA" || repos[1].Name != "repoB" {
|
|
t.Fatalf("expected order [repoA, repoB] (last_push DESC), got [%s, %s]", repos[0].Name, repos[1].Name)
|
|
}
|
|
|
|
// repoA grouping
|
|
a := repos[0]
|
|
if len(a.Tags) != 2 {
|
|
t.Errorf("repoA: expected 2 tags, got %d", len(a.Tags))
|
|
}
|
|
// tags ordered created_at DESC → v2 first
|
|
if len(a.Tags) >= 2 && (a.Tags[0].Tag != "v2" || a.Tags[1].Tag != "v1") {
|
|
t.Errorf("repoA tags out of order, want [v2, v1] got [%s, %s]", a.Tags[0].Tag, a.Tags[1].Tag)
|
|
}
|
|
if len(a.Manifests) != 2 {
|
|
t.Errorf("repoA: expected 2 manifests, got %d", len(a.Manifests))
|
|
}
|
|
// manifests ordered created_at DESC → a2 first
|
|
if len(a.Manifests) >= 2 && (a.Manifests[0].Key != manifestA2 || a.Manifests[1].Key != manifestA1) {
|
|
t.Errorf("repoA manifests out of order, want [a2, a1] got [%s, %s]", a.Manifests[0].Key, a.Manifests[1].Key)
|
|
}
|
|
if a.Title != "Repo A Title" || a.Description != "alpha" {
|
|
t.Errorf("repoA annotations not applied: title=%q desc=%q", a.Title, a.Description)
|
|
}
|
|
if a.IconURL != "https://example.com/a.png" {
|
|
t.Errorf("repoA icon: expected annotation URL, got %q", a.IconURL)
|
|
}
|
|
|
|
// repoB grouping + page-avatar override
|
|
b := repos[1]
|
|
if len(b.Tags) != 1 || b.Tags[0].Tag != "latest" {
|
|
t.Errorf("repoB tags: %#v", b.Tags)
|
|
}
|
|
if len(b.Manifests) != 1 || b.Manifests[0].Digest != "sha256:b1" {
|
|
t.Errorf("repoB manifests: %#v", b.Manifests)
|
|
}
|
|
if b.IconURL == "" {
|
|
t.Errorf("repoB icon should be derived from repo-page avatar CID, got empty")
|
|
}
|
|
|
|
// Cross-repo isolation: tags/manifests for repoB must not leak into repoA and vice versa.
|
|
for _, tag := range a.Tags {
|
|
if tag.Repository != "repoA" {
|
|
t.Errorf("repoA tag has wrong repository: %#v", tag)
|
|
}
|
|
}
|
|
for _, m := range b.Manifests {
|
|
if m.Repository != "repoB" {
|
|
t.Errorf("repoB manifest has wrong repository: %#v", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetStarredRepoCards verifies the listing of repos starred by a user:
|
|
// stars whose target repo no longer has a manifest are silently dropped (the
|
|
// "still exists" filter the feature relies on), and results are ordered by
|
|
// star creation time DESC.
|
|
func TestGetStarredRepoCards(t *testing.T) {
|
|
db, err := InitDB("file:TestGetStarredRepoCards?mode=memory&cache=shared", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
mediaType := "application/vnd.oci.image.manifest.v1+json"
|
|
|
|
starrer := &User{DID: "did:plc:starrer", Handle: "starrer.test", PDSEndpoint: "https://pds.example", LastSeen: now}
|
|
if err := UpsertUser(db, starrer); err != nil {
|
|
t.Fatalf("upsert starrer: %v", err)
|
|
}
|
|
owner := &User{DID: "did:plc:owner", Handle: "owner.test", PDSEndpoint: "https://pds.example", LastSeen: now}
|
|
if err := UpsertUser(db, owner); err != nil {
|
|
t.Fatalf("upsert owner: %v", err)
|
|
}
|
|
// Owner of a deleted repo (still has a users row, just no manifests).
|
|
ghost := &User{DID: "did:plc:ghost", Handle: "ghost.test", PDSEndpoint: "https://pds.example", LastSeen: now}
|
|
if err := UpsertUser(db, ghost); err != nil {
|
|
t.Fatalf("upsert ghost: %v", err)
|
|
}
|
|
|
|
if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
|
|
HoldDID: "did:web:hold.example", OwnerDID: "did:plc:holdowner", Public: true,
|
|
}); err != nil {
|
|
t.Fatalf("seed captain: %v", err)
|
|
}
|
|
|
|
// Two existing repos owned by `owner`.
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: owner.DID, Repository: "repo-old", Digest: "sha256:old",
|
|
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
t.Fatalf("insert old manifest: %v", err)
|
|
}
|
|
if _, err := InsertManifest(db, &Manifest{
|
|
DID: owner.DID, Repository: "repo-new", Digest: "sha256:new",
|
|
HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
|
|
CreatedAt: now.Add(5 * time.Second),
|
|
}); err != nil {
|
|
t.Fatalf("insert new manifest: %v", err)
|
|
}
|
|
|
|
// Three stars: two for existing repos, one for a deleted repo.
|
|
if err := UpsertStar(db, starrer.DID, owner.DID, "repo-old", now); err != nil {
|
|
t.Fatalf("upsert star repo-old: %v", err)
|
|
}
|
|
if err := UpsertStar(db, starrer.DID, owner.DID, "repo-new", now.Add(10*time.Second)); err != nil {
|
|
t.Fatalf("upsert star repo-new: %v", err)
|
|
}
|
|
if err := UpsertStar(db, starrer.DID, ghost.DID, "deleted-repo", now.Add(20*time.Second)); err != nil {
|
|
t.Fatalf("upsert star deleted-repo: %v", err)
|
|
}
|
|
|
|
cards, err := GetStarredRepoCards(db, starrer.DID, starrer.DID)
|
|
if err != nil {
|
|
t.Fatalf("GetStarredRepoCards: %v", err)
|
|
}
|
|
|
|
if len(cards) != 2 {
|
|
t.Fatalf("expected 2 cards (deleted repo dropped), got %d: %+v", len(cards), cards)
|
|
}
|
|
|
|
// Newest star first.
|
|
if cards[0].Repository != "repo-new" || cards[1].Repository != "repo-old" {
|
|
t.Errorf("expected order [repo-new, repo-old] (newest star first), got [%s, %s]", cards[0].Repository, cards[1].Repository)
|
|
}
|
|
|
|
// IsStarred should reflect the viewer's perspective. Viewer == starrer here
|
|
// so every returned row is starred-by-viewer.
|
|
for _, c := range cards {
|
|
if !c.IsStarred {
|
|
t.Errorf("card %s/%s expected IsStarred=true for self-viewer", c.OwnerHandle, c.Repository)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetUserRepositories_Empty verifies the bulk-fetch path short-circuits
|
|
// cleanly when the summary query returns no rows (no extra queries issued,
|
|
// nil slice returned).
|
|
func TestGetUserRepositories_Empty(t *testing.T) {
|
|
db, err := InitDB("file:TestGetUserRepositories_Empty?mode=memory&cache=shared", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("init db: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
user := &User{DID: "did:plc:nobody", Handle: "nobody.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("upsert user: %v", err)
|
|
}
|
|
|
|
repos, err := GetUserRepositories(db, user.DID, "")
|
|
if err != nil {
|
|
t.Fatalf("GetUserRepositories empty: %v", err)
|
|
}
|
|
if repos != nil {
|
|
t.Errorf("expected nil slice for user with no repos, got %#v", repos)
|
|
}
|
|
}
|
|
|
|
// TestGetRepoCards_NullLastPushStillSortsByCreatedAt is a regression test for
|
|
// the "What's New" NULL-poison bug: a repo whose repository_stats row exists
|
|
// but has last_push = NULL (e.g. created by a pull when the push-notify never
|
|
// recorded a push) must still appear and sort by its manifest created_at.
|
|
//
|
|
// SQLite's scalar 2-arg max() returns NULL if any argument is NULL, so the old
|
|
// `MAX(rs.last_push, m.created_at)` produced a NULL sort key for such repos,
|
|
// dropping them to the bottom (and out of the limited "What's New" list) and
|
|
// rendering a blank "last updated" timestamp. The fix wraps last_push in
|
|
// COALESCE so it falls back to created_at.
|
|
func TestGetRepoCards_NullLastPushStillSortsByCreatedAt(t *testing.T) {
|
|
d := setupBatchTestDB(t)
|
|
createBatchTestUser(t, d, "did:plc:alice")
|
|
|
|
// A public hold so the manifests are accessible to anonymous viewers.
|
|
if _, err := d.Exec(`
|
|
INSERT INTO hold_captain_records (hold_did, owner_did, public, allow_all_crew)
|
|
VALUES ('did:web:hold', 'did:plc:alice', 1, 0)
|
|
`); err != nil {
|
|
t.Fatalf("seed captain: %v", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
old := now.Add(-240 * time.Hour) // 10 days ago
|
|
|
|
// "fresh": recent push, but its stats row has last_push = NULL (pulled,
|
|
// never push-recorded). "stale": older, with a real last_push.
|
|
if err := BatchInsertManifests(d, []Manifest{
|
|
{DID: "did:plc:alice", Repository: "fresh", Digest: "sha256:fresh", HoldEndpoint: "did:web:hold", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", ArtifactType: "container-image", CreatedAt: now},
|
|
{DID: "did:plc:alice", Repository: "stale", Digest: "sha256:stale", HoldEndpoint: "did:web:hold", SchemaVersion: 2, MediaType: "application/vnd.oci.image.manifest.v1+json", ArtifactType: "container-image", CreatedAt: old},
|
|
}); err != nil {
|
|
t.Fatalf("insert manifests: %v", err)
|
|
}
|
|
|
|
// fresh: pull-only stats row, last_push explicitly NULL (the bug trigger).
|
|
if _, err := d.Exec(`
|
|
INSERT INTO repository_stats (did, repository, pull_count, last_pull, push_count, last_push)
|
|
VALUES ('did:plc:alice', 'fresh', 2, ?, 0, NULL)
|
|
`, now.Format(time.RFC3339)); err != nil {
|
|
t.Fatalf("seed fresh stats: %v", err)
|
|
}
|
|
// stale: has a genuine last_push, older than fresh's created_at.
|
|
if _, err := d.Exec(`
|
|
INSERT INTO repository_stats (did, repository, pull_count, last_pull, push_count, last_push)
|
|
VALUES ('did:plc:alice', 'stale', 0, NULL, 1, ?)
|
|
`, old.Format(time.RFC3339)); err != nil {
|
|
t.Fatalf("seed stale stats: %v", err)
|
|
}
|
|
|
|
cards, err := GetRepoCards(d, 18, "", SortByLastUpdate)
|
|
if err != nil {
|
|
t.Fatalf("GetRepoCards: %v", err)
|
|
}
|
|
if len(cards) != 2 {
|
|
t.Fatalf("expected 2 cards, got %d: %#v", len(cards), cards)
|
|
}
|
|
|
|
// fresh (recent created_at) must sort above stale (old last_push), despite
|
|
// fresh's last_push being NULL.
|
|
if cards[0].Repository != "fresh" {
|
|
t.Errorf("expected 'fresh' first (sorted by created_at), got order %q, %q", cards[0].Repository, cards[1].Repository)
|
|
}
|
|
|
|
// And its "last updated" must be populated from created_at, not blank.
|
|
var fresh *RepoCardData
|
|
for i := range cards {
|
|
if cards[i].Repository == "fresh" {
|
|
fresh = &cards[i]
|
|
}
|
|
}
|
|
if fresh == nil {
|
|
t.Fatal("'fresh' repo missing from What's New")
|
|
}
|
|
if fresh.LastUpdated.IsZero() {
|
|
t.Error("expected 'fresh' LastUpdated to fall back to created_at, got zero time")
|
|
}
|
|
}
|