mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
1990 lines
63 KiB
Go
1990 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 (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, 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 (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, 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 (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, 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 == 0 {
|
|
t.Error("Expected non-zero manifest ID")
|
|
}
|
|
|
|
// 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.ID != id1 {
|
|
t.Errorf("Expected ID %d, got %d", id1, retrieved.ID)
|
|
}
|
|
|
|
// 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 == 0 {
|
|
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)
|
|
}
|
|
// ID should be the same as the original insert (UPDATE, not INSERT)
|
|
if id3 != id1 {
|
|
t.Errorf("Expected upsert to return same ID %d, got %d", 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{
|
|
ManifestID: manifestID2,
|
|
Digest: "sha256:amd64",
|
|
Size: 1000,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformOS: "linux",
|
|
PlatformArchitecture: "amd64",
|
|
ReferenceIndex: 0,
|
|
}
|
|
ref2 := &ManifestReference{
|
|
ManifestID: 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{
|
|
ManifestID: 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_id = ?`, 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_id = ?`, 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 (did, repository, digest, hold_endpoint, schema_version, media_type, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, "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 manifestID int64
|
|
db.QueryRow(`SELECT id FROM manifests WHERE digest = ?`, "sha256:indexabc").Scan(&manifestID)
|
|
|
|
// Insert a child manifest reference
|
|
_, err = db.Exec(`
|
|
INSERT INTO manifest_references (manifest_id, digest, media_type, size, platform_architecture, platform_os, reference_index)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, manifestID, "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) int64 {
|
|
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, parentID int64, childDigest string, idx int) {
|
|
t.Helper()
|
|
err := InsertManifestReference(db, &ManifestReference{
|
|
ManifestID: parentID,
|
|
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].ID != manifestA2 || a.Manifests[1].ID != manifestA1) {
|
|
t.Errorf("repoA manifests out of order, want [a2, a1] got [%d, %d]", a.Manifests[0].ID, a.Manifests[1].ID)
|
|
}
|
|
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")
|
|
}
|
|
}
|