diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 20b35c8..c86fc1b 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -420,6 +420,14 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S }, )).Methods("GET") + // Manifest detail API endpoint + router.Handle("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(sessionStore, database)( + &uihandlers.ManifestDetailHandler{ + DB: readOnlyDB, + Directory: oauthApp.Directory(), + }, + )).Methods("GET") + router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( &uihandlers.UserPageHandler{ DB: readOnlyDB, @@ -453,11 +461,13 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S }).Methods("POST") authRouter.Handle("/api/images/{repository}/tags/{tag}", &uihandlers.DeleteTagHandler{ - DB: database, + DB: database, + Refresher: refresher, }).Methods("DELETE") authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ - DB: database, + DB: database, + Refresher: refresher, }).Methods("DELETE") // Device approval page (authenticated) diff --git a/lexicons/io/atcr/manifest.json b/lexicons/io/atcr/manifest.json index c0e50ef..781df9c 100644 --- a/lexicons/io/atcr/manifest.json +++ b/lexicons/io/atcr/manifest.json @@ -8,7 +8,7 @@ "key": "tid", "record": { "type": "object", - "required": ["repository", "digest", "mediaType", "schemaVersion", "config", "layers", "holdEndpoint", "createdAt"], + "required": ["repository", "digest", "mediaType", "schemaVersion", "holdEndpoint", "createdAt"], "properties": { "repository": { "type": "string", @@ -29,7 +29,9 @@ "description": "OCI media type", "knownValues": [ "application/vnd.oci.image.manifest.v1+json", - "application/vnd.docker.distribution.manifest.v2+json" + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json" ] }, "schemaVersion": { @@ -47,7 +49,15 @@ "type": "ref", "ref": "#blobReference" }, - "description": "Filesystem layers" + "description": "Filesystem layers (for image manifests)" + }, + "manifests": { + "type": "array", + "items": { + "type": "ref", + "ref": "#manifestReference" + }, + "description": "Referenced manifests (for manifest lists/indexes)" }, "annotations": { "type": "object", @@ -100,6 +110,64 @@ "description": "Optional metadata" } } + }, + "manifestReference": { + "type": "object", + "description": "Reference to a manifest in a manifest list/index", + "required": ["mediaType", "size", "digest"], + "properties": { + "mediaType": { + "type": "string", + "description": "Media type of the referenced manifest" + }, + "size": { + "type": "integer", + "description": "Size in bytes" + }, + "digest": { + "type": "string", + "description": "Content digest (e.g., 'sha256:...')" + }, + "platform": { + "type": "ref", + "ref": "#platform", + "description": "Platform information for this manifest" + }, + "annotations": { + "type": "object", + "description": "Optional metadata" + } + } + }, + "platform": { + "type": "object", + "description": "Platform information describing OS and architecture", + "required": ["architecture", "os"], + "properties": { + "architecture": { + "type": "string", + "description": "CPU architecture (e.g., 'amd64', 'arm64', 'arm')" + }, + "os": { + "type": "string", + "description": "Operating system (e.g., 'linux', 'windows', 'darwin')" + }, + "osVersion": { + "type": "string", + "description": "Optional OS version" + }, + "osFeatures": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional OS features" + }, + "variant": { + "type": "string", + "description": "Optional CPU variant (e.g., 'v7' for ARM)" + } + } } } } diff --git a/pkg/appview/db/migrations/0004_add_manifest_references.yaml b/pkg/appview/db/migrations/0004_add_manifest_references.yaml new file mode 100644 index 0000000..328d401 --- /dev/null +++ b/pkg/appview/db/migrations/0004_add_manifest_references.yaml @@ -0,0 +1,16 @@ +description: Add manifest_references table for multi-arch manifest support +query: | + CREATE TABLE IF NOT EXISTS manifest_references ( + manifest_id INTEGER NOT NULL, + digest TEXT NOT NULL, + media_type TEXT NOT NULL, + size INTEGER NOT NULL, + platform_architecture TEXT, + platform_os TEXT, + platform_variant TEXT, + platform_os_version TEXT, + reference_index INTEGER NOT NULL, + PRIMARY KEY(manifest_id, reference_index), + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index d7c87f7..53c24e2 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -40,6 +40,19 @@ type Layer struct { LayerIndex int } +// ManifestReference represents a reference to a manifest in a manifest list/index +type ManifestReference struct { + ManifestID int64 + Digest string + Size int64 + MediaType string + PlatformArchitecture string + PlatformOS string + PlatformVariant string + PlatformOSVersion string + ReferenceIndex int +} + // Tag represents a tag pointing to a manifest type Tag struct { ID int64 @@ -120,3 +133,27 @@ type RepoCardData struct { StarCount int PullCount int } + +// PlatformInfo represents platform information (OS/Architecture) +type PlatformInfo struct { + OS string + Architecture string + Variant string + OSVersion string +} + +// TagWithPlatforms extends Tag with platform information +type TagWithPlatforms struct { + Tag + Platforms []PlatformInfo + IsMultiArch bool +} + +// ManifestWithMetadata extends Manifest with tags and platform information +type ManifestWithMetadata struct { + Manifest + Tags []string + Platforms []PlatformInfo + PlatformCount int + IsManifestList bool +} diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 2d9c359..6973cae 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -484,14 +484,27 @@ func DeleteTagsNotInList(db *sql.DB, did string, keepTags []struct{ Repository, return tx.Commit() } -// InsertManifest inserts a new manifest record +// InsertManifest inserts or updates a manifest record +// Uses UPSERT to update labels/annotations if manifest already exists func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { result, err := db.Exec(` - INSERT OR IGNORE INTO manifests + INSERT INTO manifests (did, repository, digest, hold_endpoint, schema_version, media_type, config_digest, config_size, created_at, title, description, source_url, documentation_url, licenses, icon_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(did, repository, digest) DO UPDATE SET + hold_endpoint = excluded.hold_endpoint, + schema_version = excluded.schema_version, + media_type = excluded.media_type, + config_digest = excluded.config_digest, + config_size = excluded.config_size, + title = excluded.title, + description = excluded.description, + source_url = excluded.source_url, + documentation_url = excluded.documentation_url, + licenses = excluded.licenses, + icon_url = excluded.icon_url `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, manifest.ConfigSize, manifest.CreatedAt, @@ -534,6 +547,80 @@ func DeleteTag(db *sql.DB, did, repository, tag string) error { return err } +// GetTagsWithPlatforms returns all tags for a repository with platform information +// For multi-arch tags, includes all platforms from manifest_references +// For single-arch tags, includes the platform info +func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) { + rows, err := db.Query(` + SELECT + t.id, + t.did, + t.repository, + t.tag, + t.digest, + t.created_at, + m.media_type, + COALESCE(mr.platform_os, '') as platform_os, + COALESCE(mr.platform_architecture, '') as platform_architecture, + COALESCE(mr.platform_variant, '') as platform_variant, + COALESCE(mr.platform_os_version, '') as platform_os_version + FROM tags t + JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository + LEFT JOIN manifest_references mr ON m.id = mr.manifest_id + WHERE t.did = ? AND t.repository = ? + ORDER BY t.created_at DESC, mr.reference_index + `, did, repository) + + if err != nil { + return nil, err + } + defer rows.Close() + + // Group platforms by tag + tagMap := make(map[string]*TagWithPlatforms) + var tagOrder []string // Preserve order + + for rows.Next() { + var t Tag + var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string + + if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, + &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil { + return nil, err + } + + // Get or create TagWithPlatforms + tagKey := t.Tag + if _, exists := tagMap[tagKey]; !exists { + tagMap[tagKey] = &TagWithPlatforms{ + Tag: t, + Platforms: []PlatformInfo{}, + } + tagOrder = append(tagOrder, tagKey) + } + + // Add platform info if present + if platformOS != "" || platformArch != "" { + tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{ + OS: platformOS, + Architecture: platformArch, + Variant: platformVariant, + OSVersion: platformOSVersion, + }) + } + } + + // Convert map to slice, preserving order and setting IsMultiArch + result := make([]TagWithPlatforms, 0, len(tagMap)) + for _, tagKey := range tagOrder { + tag := tagMap[tagKey] + tag.IsMultiArch = len(tag.Platforms) > 1 + result = append(result, *tag) + } + + return result, nil +} + // DeleteManifest deletes a manifest and its associated layers // If repository is empty, deletes all manifests matching did and digest func DeleteManifest(db *sql.DB, did, repository, digest string) error { @@ -619,6 +706,278 @@ func GetLayersForManifest(db *sql.DB, manifestID int64) ([]Layer, error) { return layers, nil } +// InsertManifestReference inserts a new manifest reference record (for manifest lists/indexes) +func InsertManifestReference(db *sql.DB, ref *ManifestReference) error { + _, err := db.Exec(` + INSERT INTO manifest_references (manifest_id, digest, size, media_type, + platform_architecture, platform_os, + platform_variant, platform_os_version, + reference_index) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType, + ref.PlatformArchitecture, ref.PlatformOS, + ref.PlatformVariant, ref.PlatformOSVersion, + ref.ReferenceIndex) + return err +} + +// GetManifestReferencesForManifest fetches all manifest references for a manifest list/index +func GetManifestReferencesForManifest(db *sql.DB, manifestID int64) ([]ManifestReference, error) { + rows, err := db.Query(` + SELECT manifest_id, digest, size, media_type, + platform_architecture, platform_os, platform_variant, platform_os_version, + reference_index + FROM manifest_references + WHERE manifest_id = ? + ORDER BY reference_index + `, manifestID) + + if err != nil { + return nil, err + } + defer rows.Close() + + var refs []ManifestReference + for rows.Next() { + var r ManifestReference + var arch, os, variant, osVersion sql.NullString + if err := rows.Scan(&r.ManifestID, &r.Digest, &r.Size, &r.MediaType, + &arch, &os, &variant, &osVersion, + &r.ReferenceIndex); err != nil { + return nil, err + } + + // Convert nullable strings + if arch.Valid { + r.PlatformArchitecture = arch.String + } + if os.Valid { + r.PlatformOS = os.String + } + if variant.Valid { + r.PlatformVariant = variant.String + } + if osVersion.Valid { + r.PlatformOSVersion = osVersion.String + } + + refs = append(refs, r) + } + + return refs, nil +} + +// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests +// Filters out platform-specific manifests that are referenced by manifest lists +func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { + rows, err := db.Query(` + WITH manifest_list_children AS ( + -- Get all digests that are children of manifest lists + SELECT DISTINCT mr.digest + FROM manifest_references mr + JOIN manifests m ON mr.manifest_id = m.id + WHERE m.did = ? AND m.repository = ? + ) + SELECT + m.id, m.did, m.repository, m.digest, m.media_type, + m.schema_version, m.created_at, m.title, m.description, + m.source_url, m.documentation_url, m.licenses, m.icon_url, + m.config_digest, m.config_size, m.hold_endpoint, + GROUP_CONCAT(DISTINCT t.tag) as tags, + COUNT(DISTINCT mr.digest) as platform_count + FROM manifests m + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository + LEFT JOIN manifest_references mr ON m.id = mr.manifest_id + WHERE m.did = ? AND m.repository = ? + AND ( + -- Include manifest lists + m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' + OR + -- Include single-arch NOT referenced by any list + m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) + ) + GROUP BY m.id + ORDER BY m.created_at DESC + LIMIT ? OFFSET ? + `, did, repository, did, repository, limit, offset) + + if err != nil { + return nil, err + } + defer rows.Close() + + var manifests []ManifestWithMetadata + for rows.Next() { + var m ManifestWithMetadata + var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString + var configSize sql.NullInt64 + + if err := rows.Scan( + &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, + &m.SchemaVersion, &m.CreatedAt, &title, &description, + &sourceURL, &documentationURL, &licenses, &iconURL, + &configDigest, &configSize, &m.HoldEndpoint, + &tags, &m.PlatformCount, + ); err != nil { + return nil, err + } + + // Set nullable fields + if title.Valid { + m.Title = title.String + } + if description.Valid { + m.Description = description.String + } + if sourceURL.Valid { + m.SourceURL = sourceURL.String + } + if documentationURL.Valid { + m.DocumentationURL = documentationURL.String + } + if licenses.Valid { + m.Licenses = licenses.String + } + if iconURL.Valid { + m.IconURL = iconURL.String + } + if configDigest.Valid { + m.ConfigDigest = configDigest.String + } + if configSize.Valid { + m.ConfigSize = configSize.Int64 + } + + // Parse tags + if tags.Valid && tags.String != "" { + m.Tags = strings.Split(tags.String, ",") + } + + // Determine if manifest list + m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list") + + manifests = append(manifests, m) + } + + return manifests, nil +} + +// GetManifestDetail returns a manifest with full platform details and tags +func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) { + // First, get the manifest and its tags + var m ManifestWithMetadata + var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString + var configSize sql.NullInt64 + + err := db.QueryRow(` + SELECT + m.id, m.did, m.repository, m.digest, m.media_type, + m.schema_version, m.created_at, m.title, m.description, + m.source_url, m.documentation_url, m.licenses, m.icon_url, + m.config_digest, m.config_size, m.hold_endpoint, + GROUP_CONCAT(DISTINCT t.tag) as tags + FROM manifests m + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository + WHERE m.did = ? AND m.repository = ? AND m.digest = ? + GROUP BY m.id + `, did, repository, digest).Scan( + &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, + &m.SchemaVersion, &m.CreatedAt, &title, &description, + &sourceURL, &documentationURL, &licenses, &iconURL, + &configDigest, &configSize, &m.HoldEndpoint, + &tags, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("manifest not found") + } + return nil, err + } + + // Set nullable fields + if title.Valid { + m.Title = title.String + } + if description.Valid { + m.Description = description.String + } + if sourceURL.Valid { + m.SourceURL = sourceURL.String + } + if documentationURL.Valid { + m.DocumentationURL = documentationURL.String + } + if licenses.Valid { + m.Licenses = licenses.String + } + if iconURL.Valid { + m.IconURL = iconURL.String + } + if configDigest.Valid { + m.ConfigDigest = configDigest.String + } + if configSize.Valid { + m.ConfigSize = configSize.Int64 + } + + // Parse tags + if tags.Valid && tags.String != "" { + m.Tags = strings.Split(tags.String, ",") + } + + // Determine if manifest list + m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list") + + // If this is a manifest list, get platform details + if m.IsManifestList { + platforms, err := db.Query(` + SELECT + mr.platform_os, + mr.platform_architecture, + mr.platform_variant, + mr.platform_os_version + FROM manifest_references mr + WHERE mr.manifest_id = ? + ORDER BY mr.reference_index + `, m.ID) + + if err != nil { + return nil, err + } + defer platforms.Close() + + m.Platforms = []PlatformInfo{} + for platforms.Next() { + var p PlatformInfo + var os, arch, variant, osVersion sql.NullString + + if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil { + return nil, err + } + + if os.Valid { + p.OS = os.String + } + if arch.Valid { + p.Architecture = arch.String + } + if variant.Valid { + p.Variant = variant.String + } + if osVersion.Valid { + p.OSVersion = osVersion.String + } + + m.Platforms = append(m.Platforms, p) + } + + m.PlatformCount = len(m.Platforms) + } + + return &m, nil +} + // GetFirehoseCursor retrieves the current firehose cursor func GetFirehoseCursor(db *sql.DB) (int64, error) { var cursor int64 diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go index 2af4624..4deb4dc 100644 --- a/pkg/appview/db/schema.go +++ b/pkg/appview/db/schema.go @@ -68,6 +68,21 @@ CREATE TABLE IF NOT EXISTS layers ( ); CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); +CREATE TABLE IF NOT EXISTS manifest_references ( + manifest_id INTEGER NOT NULL, + digest TEXT NOT NULL, + media_type TEXT NOT NULL, + size INTEGER NOT NULL, + platform_architecture TEXT, + platform_os TEXT, + platform_variant TEXT, + platform_os_version TEXT, + reference_index INTEGER NOT NULL, + PRIMARY KEY(manifest_id, reference_index), + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); + CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go index fe40f20..90d0df8 100644 --- a/pkg/appview/handlers/api.go +++ b/pkg/appview/handlers/api.go @@ -223,6 +223,43 @@ func (h *GetStatsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(stats) } +// ManifestDetailHandler returns detailed manifest information including platforms +type ManifestDetailHandler struct { + DB *sql.DB + Directory identity.Directory +} + +func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Extract parameters + vars := mux.Vars(r) + handle := vars["handle"] + repository := vars["repository"] + digest := vars["digest"] + + // Resolve owner's handle to DID + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) + if err != nil { + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) + return + } + + // Get manifest detail from database + manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest) + if err != nil { + if err.Error() == "manifest not found" { + http.Error(w, "Manifest not found", http.StatusNotFound) + return + } + log.Printf("GetManifestDetail error: %v", err) + http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError) + return + } + + // Return manifest as JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(manifest) +} + // resolveIdentityToDID is a helper function that resolves a handle or DID to a DID func resolveIdentityToDID(ctx context.Context, directory identity.Directory, identityStr string) (string, error) { // Parse as AT identifier (handle or DID) diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 632b671..7ad1115 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -2,17 +2,21 @@ package handlers import ( "database/sql" + "fmt" "net/http" + "strings" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" + "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/oauth" "github.com/gorilla/mux" ) // DeleteTagHandler handles deleting a tag type DeleteTagHandler struct { - DB *sql.DB - // TODO: Add ATProto client for deleting from PDS + DB *sql.DB + Refresher *oauth.Refresher } func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -26,7 +30,26 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { repo := vars["repository"] tag := vars["tag"] - // TODO: Delete from PDS via ATProto client + // Get OAuth session for the authenticated user + session, err := h.Refresher.GetSession(r.Context(), user.DID) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) + return + } + + // Create ATProto client with OAuth credentials + apiClient := session.APIClient() + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) + + // Compute rkey for tag record (repository_tag with slashes replaced) + rkey := fmt.Sprintf("%s_%s", repo, tag) + rkey = strings.ReplaceAll(rkey, "/", "-") + + // Delete from PDS first + if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, rkey); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete tag from PDS: %v", err), http.StatusInternalServerError) + return + } // Delete from cache if err := db.DeleteTag(h.DB, user.DID, repo, tag); err != nil { @@ -40,8 +63,8 @@ func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // DeleteManifestHandler handles deleting a manifest type DeleteManifestHandler struct { - DB *sql.DB - // TODO: Add ATProto client for deleting from PDS + DB *sql.DB + Refresher *oauth.Refresher } func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -67,7 +90,25 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } - // TODO: Delete from PDS via ATProto client + // Get OAuth session for the authenticated user + session, err := h.Refresher.GetSession(r.Context(), user.DID) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) + return + } + + // Create ATProto client with OAuth credentials + apiClient := session.APIClient() + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) + + // Compute rkey for manifest record (digest without "sha256:" prefix) + rkey := strings.TrimPrefix(digest, "sha256:") + + // Delete from PDS first + if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete manifest from PDS: %v", err), http.StatusInternalServerError) + return + } // Delete from cache if err := db.DeleteManifest(h.DB, user.DID, repo, digest); err != nil { diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 8a23fef..8d37c26 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -40,18 +40,32 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } - // Fetch repository data - repo, err := db.GetRepository(h.DB, owner.DID, repository) + // Fetch tags with platform information + tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.DB, owner.DID, repository) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - if repo == nil || len(repo.Manifests) == 0 { + // Fetch top-level manifests (filters out platform-specific manifests) + manifests, err := db.GetTopLevelManifests(h.DB, owner.DID, repository, 50, 0) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if len(tagsWithPlatforms) == 0 && len(manifests) == 0 { http.Error(w, "Repository not found", http.StatusNotFound) return } + // Create repository summary + repo := &db.Repository{ + Name: repository, + TagCount: len(tagsWithPlatforms), + ManifestCount: len(manifests), + } + // Fetch star count stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository) if err != nil { @@ -86,15 +100,19 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request data := struct { PageData - Owner *db.User // Repository owner - Repository *db.Repository - StarCount int - IsStarred bool - IsOwner bool // Whether current user owns this repository + Owner *db.User // Repository owner + Repository *db.Repository // Repository summary + Tags []db.TagWithPlatforms // Tags with platform info + Manifests []db.ManifestWithMetadata // Top-level manifests only + StarCount int + IsStarred bool + IsOwner bool // Whether current user owns this repository }{ PageData: NewPageData(r, h.RegistryURL), Owner: owner, Repository: repo, + Tags: tagsWithPlatforms, + Manifests: manifests, StarCount: stats.StarCount, IsStarred: isStarred, IsOwner: isOwner, diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 7471895..1dc1911 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -308,15 +308,16 @@ func (b *BackfillWorker) processManifestRecord(did string, record *atproto.Recor iconURL = manifestRecord.Annotations["io.atcr.icon"] } - // Insert manifest - manifestID, err := db.InsertManifest(b.db, &db.Manifest{ + // Detect manifest type + isManifestList := len(manifestRecord.Manifests) > 0 + + // Prepare manifest for insertion + manifest := &db.Manifest{ DID: did, Repository: manifestRecord.Repository, Digest: manifestRecord.Digest, MediaType: manifestRecord.MediaType, SchemaVersion: manifestRecord.SchemaVersion, - ConfigDigest: manifestRecord.Config.Digest, - ConfigSize: manifestRecord.Config.Size, HoldEndpoint: manifestRecord.HoldEndpoint, CreatedAt: manifestRecord.CreatedAt, Title: title, @@ -325,7 +326,16 @@ func (b *BackfillWorker) processManifestRecord(did string, record *atproto.Recor DocumentationURL: documentationURL, Licenses: licenses, IconURL: iconURL, - }) + } + + // Set config fields only for image manifests (not manifest lists) + if !isManifestList && manifestRecord.Config != nil { + manifest.ConfigDigest = manifestRecord.Config.Digest + manifest.ConfigSize = manifestRecord.Config.Size + } + + // Insert manifest + manifestID, err := db.InsertManifest(b.db, manifest) if err != nil { // Skip if already exists if strings.Contains(err.Error(), "UNIQUE constraint failed") { @@ -334,17 +344,49 @@ func (b *BackfillWorker) processManifestRecord(did string, record *atproto.Recor return fmt.Errorf("failed to insert manifest: %w", err) } - // Insert layers - for i, layer := range manifestRecord.Layers { - if err := db.InsertLayer(b.db, &db.Layer{ - ManifestID: manifestID, - Digest: layer.Digest, - MediaType: layer.MediaType, - Size: layer.Size, - LayerIndex: i, - }); err != nil { - // Continue on error - layer might already exist - continue + if isManifestList { + // Insert manifest references (for manifest lists/indexes) + for i, ref := range manifestRecord.Manifests { + platformArch := "" + platformOS := "" + platformVariant := "" + platformOSVersion := "" + + if ref.Platform != nil { + platformArch = ref.Platform.Architecture + platformOS = ref.Platform.OS + platformVariant = ref.Platform.Variant + platformOSVersion = ref.Platform.OSVersion + } + + if err := db.InsertManifestReference(b.db, &db.ManifestReference{ + ManifestID: manifestID, + Digest: ref.Digest, + MediaType: ref.MediaType, + Size: ref.Size, + PlatformArchitecture: platformArch, + PlatformOS: platformOS, + PlatformVariant: platformVariant, + PlatformOSVersion: platformOSVersion, + ReferenceIndex: i, + }); err != nil { + // Continue on error - reference might already exist + continue + } + } + } else { + // Insert layers (for image manifests) + for i, layer := range manifestRecord.Layers { + if err := db.InsertLayer(b.db, &db.Layer{ + ManifestID: manifestID, + Digest: layer.Digest, + MediaType: layer.MediaType, + Size: layer.Size, + LayerIndex: i, + }); err != nil { + // Continue on error - layer might already exist + continue + } } } diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index 73f3990..09d5c1a 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -452,15 +452,16 @@ func (w *Worker) processManifest(commit *CommitEvent) error { iconURL = manifestRecord.Annotations["io.atcr.icon"] } - // Insert manifest - manifestID, err := db.InsertManifest(w.db, &db.Manifest{ + // Detect manifest type + isManifestList := len(manifestRecord.Manifests) > 0 + + // Prepare manifest for insertion + manifest := &db.Manifest{ DID: commit.DID, Repository: manifestRecord.Repository, Digest: manifestRecord.Digest, MediaType: manifestRecord.MediaType, SchemaVersion: manifestRecord.SchemaVersion, - ConfigDigest: manifestRecord.Config.Digest, - ConfigSize: manifestRecord.Config.Size, HoldEndpoint: manifestRecord.HoldEndpoint, CreatedAt: manifestRecord.CreatedAt, Title: title, @@ -469,22 +470,63 @@ func (w *Worker) processManifest(commit *CommitEvent) error { DocumentationURL: documentationURL, Licenses: licenses, IconURL: iconURL, - }) + } + + // Set config fields only for image manifests (not manifest lists) + if !isManifestList && manifestRecord.Config != nil { + manifest.ConfigDigest = manifestRecord.Config.Digest + manifest.ConfigSize = manifestRecord.Config.Size + } + + // Insert manifest + manifestID, err := db.InsertManifest(w.db, manifest) if err != nil { return fmt.Errorf("failed to insert manifest: %w", err) } - // Insert layers - for i, layer := range manifestRecord.Layers { - if err := db.InsertLayer(w.db, &db.Layer{ - ManifestID: manifestID, - Digest: layer.Digest, - MediaType: layer.MediaType, - Size: layer.Size, - LayerIndex: i, - }); err != nil { - // Continue on error - layer might already exist - continue + if isManifestList { + // Insert manifest references (for manifest lists/indexes) + for i, ref := range manifestRecord.Manifests { + platformArch := "" + platformOS := "" + platformVariant := "" + platformOSVersion := "" + + if ref.Platform != nil { + platformArch = ref.Platform.Architecture + platformOS = ref.Platform.OS + platformVariant = ref.Platform.Variant + platformOSVersion = ref.Platform.OSVersion + } + + if err := db.InsertManifestReference(w.db, &db.ManifestReference{ + ManifestID: manifestID, + Digest: ref.Digest, + MediaType: ref.MediaType, + Size: ref.Size, + PlatformArchitecture: platformArch, + PlatformOS: platformOS, + PlatformVariant: platformVariant, + PlatformOSVersion: platformOSVersion, + ReferenceIndex: i, + }); err != nil { + // Continue on error - reference might already exist + continue + } + } + } else { + // Insert layers (for image manifests) + for i, layer := range manifestRecord.Layers { + if err := db.InsertLayer(w.db, &db.Layer{ + ManifestID: manifestID, + Digest: layer.Digest, + MediaType: layer.MediaType, + Size: layer.Size, + LayerIndex: i, + }); err != nil { + // Continue on error - layer might already exist + continue + } } } diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index 027efa6..292afe5 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -1049,6 +1049,61 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { color: var(--secondary); } +/* Multi-architecture badges */ +.badge-multi { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 12px; + background: var(--primary); + color: var(--bg); + white-space: nowrap; + margin-left: 0.5rem; +} + +.platform-badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + border-radius: 4px; + background: var(--code-bg); + color: var(--fg); + border: 1px solid var(--border); + white-space: nowrap; + font-family: 'Monaco', 'Courier New', monospace; +} + +.platforms-inline { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.manifest-type { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--secondary); +} + +.platform-count { + color: var(--border-dark); + font-size: 0.85rem; + font-style: italic; +} + +.text-muted { + color: var(--border-dark); + font-style: italic; +} + /* Featured Repositories Section */ .featured-section { margin-bottom: 3rem; diff --git a/pkg/appview/storage/proxy_blob_store.go b/pkg/appview/storage/proxy_blob_store.go index ef910b4..9c98df4 100644 --- a/pkg/appview/storage/proxy_blob_store.go +++ b/pkg/appview/storage/proxy_blob_store.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "atcr.io/pkg/atproto" "github.com/distribution/distribution/v3" "github.com/opencontainers/go-digest" ) @@ -508,7 +509,7 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string return "", err } - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.initiateUpload", p.holdURL) + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldInitiateUpload) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return "", err @@ -556,7 +557,7 @@ func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID return nil, err } - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.getPartUploadUrl", p.holdURL) + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldGetPartUploadUrl) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, err @@ -606,7 +607,7 @@ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, up return err } - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.completeUpload", p.holdURL) + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldCompleteUpload) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err @@ -639,7 +640,7 @@ func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploa return err } - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.abortUpload", p.holdURL) + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldAbortUpload) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err diff --git a/pkg/appview/storage/proxy_blob_store_test.go b/pkg/appview/storage/proxy_blob_store_test.go index 77002d6..6fa689b 100644 --- a/pkg/appview/storage/proxy_blob_store_test.go +++ b/pkg/appview/storage/proxy_blob_store_test.go @@ -2,11 +2,15 @@ package storage import ( "context" + "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" + + "atcr.io/pkg/atproto" + "github.com/opencontainers/go-digest" ) // TestGetServiceToken_CachingLogic tests the token caching mechanism @@ -343,3 +347,325 @@ func BenchmarkServiceTokenCacheAccess(b *testing.B) { } } } + +// TestCompleteMultipartUpload_JSONFormat verifies the JSON request format sent to hold service +// This test would have caught the "partNumber" vs "part_number" bug +func TestCompleteMultipartUpload_JSONFormat(t *testing.T) { + var capturedBody map[string]any + + // Mock hold service that captures the request body + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, atproto.HoldCompleteUpload) { + t.Errorf("Wrong endpoint called: %s", r.URL.Path) + } + + // Capture request body + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + capturedBody = body + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer holdServer.Close() + + // Create store with mocked hold URL + ctx := &RegistryContext{ + DID: "did:plc:test", + HoldDID: "did:web:hold.example.com", + PDSEndpoint: "https://pds.example.com", + Repository: "test-repo", + } + store := NewProxyBlobStore(ctx) + store.holdURL = holdServer.URL + + // Setup token cache to avoid auth errors + globalServiceTokensMu.Lock() + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ + token: "test-token", + expiresAt: time.Now().Add(50 * time.Second), + } + globalServiceTokensMu.Unlock() + + // Call completeMultipartUpload + parts := []CompletedPart{ + {PartNumber: 1, ETag: "etag-1"}, + {PartNumber: 2, ETag: "etag-2"}, + } + err := store.completeMultipartUpload(context.Background(), "sha256:abc123", "upload-id-xyz", parts) + if err != nil { + t.Fatalf("completeMultipartUpload failed: %v", err) + } + + // Verify JSON format + if capturedBody == nil { + t.Fatal("No request body was captured") + } + + // Check top-level fields + if uploadID, ok := capturedBody["uploadId"].(string); !ok || uploadID != "upload-id-xyz" { + t.Errorf("Expected uploadId='upload-id-xyz', got %v", capturedBody["uploadId"]) + } + if digest, ok := capturedBody["digest"].(string); !ok || digest != "sha256:abc123" { + t.Errorf("Expected digest='sha256:abc123', got %v", capturedBody["digest"]) + } + + // Check parts array + partsArray, ok := capturedBody["parts"].([]any) + if !ok { + t.Fatalf("Expected parts to be array, got %T", capturedBody["parts"]) + } + if len(partsArray) != 2 { + t.Fatalf("Expected 2 parts, got %d", len(partsArray)) + } + + // Verify first part has "part_number" (not "partNumber") + part0, ok := partsArray[0].(map[string]any) + if !ok { + t.Fatalf("Expected part to be object, got %T", partsArray[0]) + } + + // THIS IS THE KEY CHECK - would have caught the bug + if _, hasPartNumber := part0["partNumber"]; hasPartNumber { + t.Error("Found 'partNumber' (camelCase) - should be 'part_number' (snake_case)") + } + if partNum, ok := part0["part_number"].(float64); !ok || int(partNum) != 1 { + t.Errorf("Expected part_number=1, got %v", part0["part_number"]) + } + if etag, ok := part0["etag"].(string); !ok || etag != "etag-1" { + t.Errorf("Expected etag='etag-1', got %v", part0["etag"]) + } +} + +// TestGet_UsesPresignedURLDirectly verifies that Get() doesn't add auth headers to presigned URLs +// This test would have caught the presigned URL authentication bug +func TestGet_UsesPresignedURLDirectly(t *testing.T) { + blobData := []byte("test blob content") + var s3ReceivedAuthHeader string + + // Mock S3 server that rejects requests with Authorization header + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s3ReceivedAuthHeader = r.Header.Get("Authorization") + + // Presigned URLs should NOT have Authorization header + if s3ReceivedAuthHeader != "" { + t.Errorf("S3 received Authorization header: %s (should be empty for presigned URLs)", s3ReceivedAuthHeader) + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`SignatureDoesNotMatch`)) + return + } + + // Return blob data + w.WriteHeader(http.StatusOK) + w.Write(blobData) + })) + defer s3Server.Close() + + // Mock hold service that returns presigned S3 URL + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return presigned URL pointing to S3 server + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := map[string]string{ + "url": s3Server.URL + "/blob?X-Amz-Signature=fake-signature", + } + json.NewEncoder(w).Encode(resp) + })) + defer holdServer.Close() + + // Create store + ctx := &RegistryContext{ + DID: "did:plc:test", + HoldDID: "did:web:hold.example.com", + PDSEndpoint: "https://pds.example.com", + Repository: "test-repo", + } + store := NewProxyBlobStore(ctx) + store.holdURL = holdServer.URL + + // Setup token cache + globalServiceTokensMu.Lock() + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ + token: "test-token", + expiresAt: time.Now().Add(50 * time.Second), + } + globalServiceTokensMu.Unlock() + + // Call Get() + dgst := digest.FromBytes(blobData) + retrieved, err := store.Get(context.Background(), dgst) + if err != nil { + t.Fatalf("Get() failed: %v", err) + } + + // Verify correct data was retrieved + if string(retrieved) != string(blobData) { + t.Errorf("Expected data=%s, got %s", string(blobData), string(retrieved)) + } + + // Verify S3 received NO Authorization header + if s3ReceivedAuthHeader != "" { + t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader) + } +} + +// TestOpen_UsesPresignedURLDirectly verifies that Open() doesn't add auth headers to presigned URLs +// This test would have caught the presigned URL authentication bug +func TestOpen_UsesPresignedURLDirectly(t *testing.T) { + blobData := []byte("test blob stream content") + var s3ReceivedAuthHeader string + + // Mock S3 server + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s3ReceivedAuthHeader = r.Header.Get("Authorization") + + // Presigned URLs should NOT have Authorization header + if s3ReceivedAuthHeader != "" { + t.Errorf("S3 received Authorization header: %s (should be empty)", s3ReceivedAuthHeader) + w.WriteHeader(http.StatusForbidden) + return + } + + w.WriteHeader(http.StatusOK) + w.Write(blobData) + })) + defer s3Server.Close() + + // Mock hold service + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "url": s3Server.URL + "/blob?X-Amz-Signature=fake", + }) + })) + defer holdServer.Close() + + // Create store + ctx := &RegistryContext{ + DID: "did:plc:test", + HoldDID: "did:web:hold.example.com", + PDSEndpoint: "https://pds.example.com", + Repository: "test-repo", + } + store := NewProxyBlobStore(ctx) + store.holdURL = holdServer.URL + + // Setup token cache + globalServiceTokensMu.Lock() + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ + token: "test-token", + expiresAt: time.Now().Add(50 * time.Second), + } + globalServiceTokensMu.Unlock() + + // Call Open() + dgst := digest.FromBytes(blobData) + reader, err := store.Open(context.Background(), dgst) + if err != nil { + t.Fatalf("Open() failed: %v", err) + } + defer reader.Close() + + // Verify S3 received NO Authorization header + if s3ReceivedAuthHeader != "" { + t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader) + } +} + +// TestMultipartEndpoints_CorrectURLs verifies all multipart XRPC endpoints use correct URLs +// This would have caught the old com.atproto.repo.uploadBlob vs new io.atcr.hold.* endpoints +func TestMultipartEndpoints_CorrectURLs(t *testing.T) { + tests := []struct { + name string + testFunc func(*ProxyBlobStore) error + expectedPath string + }{ + { + name: "startMultipartUpload", + testFunc: func(store *ProxyBlobStore) error { + _, err := store.startMultipartUpload(context.Background(), "sha256:test") + return err + }, + expectedPath: atproto.HoldInitiateUpload, + }, + { + name: "getPartUploadInfo", + testFunc: func(store *ProxyBlobStore) error { + _, err := store.getPartUploadInfo(context.Background(), "sha256:test", "upload-123", 1) + return err + }, + expectedPath: atproto.HoldGetPartUploadUrl, + }, + { + name: "completeMultipartUpload", + testFunc: func(store *ProxyBlobStore) error { + parts := []CompletedPart{{PartNumber: 1, ETag: "etag1"}} + return store.completeMultipartUpload(context.Background(), "sha256:test", "upload-123", parts) + }, + expectedPath: atproto.HoldCompleteUpload, + }, + { + name: "abortMultipartUpload", + testFunc: func(store *ProxyBlobStore) error { + return store.abortMultipartUpload(context.Background(), "sha256:test", "upload-123") + }, + expectedPath: atproto.HoldAbortUpload, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedPath string + + // Mock hold service that captures request path + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := map[string]string{ + "uploadId": "test-upload-id", + "url": "https://s3.example.com/presigned", + } + json.NewEncoder(w).Encode(resp) + })) + defer holdServer.Close() + + // Create store + ctx := &RegistryContext{ + DID: "did:plc:test", + HoldDID: "did:web:hold.example.com", + PDSEndpoint: "https://pds.example.com", + Repository: "test-repo", + } + store := NewProxyBlobStore(ctx) + store.holdURL = holdServer.URL + + // Setup token cache + globalServiceTokensMu.Lock() + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ + token: "test-token", + expiresAt: time.Now().Add(50 * time.Second), + } + globalServiceTokensMu.Unlock() + + // Call the function + _ = tt.testFunc(store) // Ignore error, we just care about the URL + + // Verify correct endpoint was called + if capturedPath != tt.expectedPath { + t.Errorf("Expected endpoint %s, got %s", tt.expectedPath, capturedPath) + } + + // Verify it's NOT the old endpoint + if strings.Contains(capturedPath, "com.atproto.repo.uploadBlob") { + t.Error("Still using old com.atproto.repo.uploadBlob endpoint!") + } + }) + } +} diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 0641762..0cb88e3 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -64,11 +64,11 @@

Pull this image

- {{ if .Repository.Tags }} - {{ $firstTag := index .Repository.Tags 0 }} + {{ if .Tags }} + {{ $firstTag := index .Tags 0 }}
- docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }} -
@@ -86,21 +86,26 @@

Tags

- {{ if .Repository.Tags }} + {{ if .Tags }}
- {{ range .Repository.Tags }} -
+ {{ range .Tags }} +
- {{ .Tag }} +
+ {{ .Tag.Tag }} + {{ if .IsMultiArch }} + Multi-arch + {{ end }} +
-
- {{ .Digest }} +
+ {{ .Tag.Digest }} + {{ if .Platforms }} +
+ {{ range .Platforms }} + {{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }} + {{ end }} +
+ {{ end }} +
- docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }} -
@@ -127,19 +141,48 @@

Manifests

- {{ if .Repository.Manifests }} + {{ if .Manifests }}
- {{ range .Repository.Manifests }} -
+ {{ range .Manifests }} +
- {{ .Digest }} - +
+ {{ if .IsManifestList }} + 📦 Multi-arch + {{ else }} + 📄 Image + {{ end }} + {{ .Manifest.Digest }} +
+
+ + {{ if $.IsOwner }} + + {{ end }} +
- Storage: - {{ .HoldEndpoint }} +
+
+ {{ if .Tags }} + Tags: + {{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }} + {{ else }} + (untagged) + {{ end }} +
+ {{ if .IsManifestList }} + {{ .PlatformCount }} platforms + {{ end }} +
{{ end }} diff --git a/pkg/appview/ui.go b/pkg/appview/ui.go index afc211e..204d644 100644 --- a/pkg/appview/ui.go +++ b/pkg/appview/ui.go @@ -6,6 +6,7 @@ import ( "html/template" "io/fs" "net/http" + "strings" "time" ) @@ -77,6 +78,12 @@ func Templates() (*template.Template, error) { } return s }, + + "sanitizeID": func(s string) string { + // Replace colons with dashes to make valid CSS selectors + // e.g., "sha256:abc123" becomes "sha256-abc123" + return strings.ReplaceAll(s, ":", "-") + }, } tmpl := template.New("").Funcs(funcMap) diff --git a/pkg/appview/ui_test.go b/pkg/appview/ui_test.go index 75fc125..d30bd88 100644 --- a/pkg/appview/ui_test.go +++ b/pkg/appview/ui_test.go @@ -437,6 +437,82 @@ func TestTrimPrefix(t *testing.T) { } } +func TestSanitizeID(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "digest with colon", + input: "sha256:abc123", + expected: "sha256-abc123", + }, + { + name: "full digest", + input: "sha256:f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", + expected: "sha256-f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", + }, + { + name: "multiple colons", + input: "sha256:abc:def:ghi", + expected: "sha256-abc-def-ghi", + }, + { + name: "no colons", + input: "abcdef123456", + expected: "abcdef123456", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "only colon", + input: ":", + expected: "-", + }, + { + name: "leading colon", + input: ":abc", + expected: "-abc", + }, + { + name: "trailing colon", + input: "abc:", + expected: "abc-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Get fresh template for each test case + tmpl, err := Templates() + if err != nil { + t.Fatalf("Templates() error = %v", err) + } + + templateStr := `{{ sanitizeID . }}` + buf := new(bytes.Buffer) + temp, err := tmpl.New("test").Parse(templateStr) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + err = temp.Execute(buf, tt.input) + if err != nil { + t.Fatalf("Failed to execute template: %v", err) + } + + got := buf.String() + if got != tt.expected { + t.Errorf("sanitizeID(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + func TestTemplates(t *testing.T) { tmpl, err := Templates() if err != nil { diff --git a/pkg/atproto/client_test.go b/pkg/atproto/client_test.go new file mode 100644 index 0000000..20c2dd3 --- /dev/null +++ b/pkg/atproto/client_test.go @@ -0,0 +1,693 @@ +package atproto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestNewClient verifies client initialization with Basic Auth +func TestNewClient(t *testing.T) { + client := NewClient("https://pds.example.com", "did:plc:test123", "token123") + + if client.pdsEndpoint != "https://pds.example.com" { + t.Errorf("pdsEndpoint = %v, want https://pds.example.com", client.pdsEndpoint) + } + if client.did != "did:plc:test123" { + t.Errorf("did = %v, want did:plc:test123", client.did) + } + if client.accessToken != "token123" { + t.Errorf("accessToken = %v, want token123", client.accessToken) + } + if client.useIndigoClient { + t.Error("useIndigoClient should be false for Basic Auth client") + } +} + +// TestPutRecord tests storing a record in ATProto +func TestPutRecord(t *testing.T) { + tests := []struct { + name string + collection string + rkey string + record interface{} + serverResponse string + serverStatus int + wantErr bool + checkFunc func(*testing.T, *Record) + }{ + { + name: "successful put", + collection: ManifestCollection, + rkey: "abc123", + record: map[string]string{ + "$type": ManifestCollection, + "test": "value", + }, + serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`, + serverStatus: http.StatusOK, + wantErr: false, + checkFunc: func(t *testing.T, r *Record) { + if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { + t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) + } + if r.CID != "bafytest" { + t.Errorf("CID = %v, want bafytest", r.CID) + } + }, + }, + { + name: "server error", + collection: ManifestCollection, + rkey: "abc123", + record: map[string]string{"test": "value"}, + serverResponse: `{"error":"InvalidRequest"}`, + serverStatus: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "POST" { + t.Errorf("Method = %v, want POST", r.Method) + } + + // Verify path + expectedPath := "/xrpc/com.atproto.repo.putRecord" + if r.URL.Path != expectedPath { + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) + } + + // Verify Authorization header + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + t.Errorf("Authorization header missing or malformed: %v", auth) + } + + // Verify request body + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + + if body["repo"] != "did:plc:test123" { + t.Errorf("repo = %v, want did:plc:test123", body["repo"]) + } + if body["collection"] != tt.collection { + t.Errorf("collection = %v, want %v", body["collection"], tt.collection) + } + if body["rkey"] != tt.rkey { + t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) + } + + // Send response + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + // Create client pointing to test server + client := NewClient(server.URL, "did:plc:test123", "test-token") + + // Call PutRecord + result, err := client.PutRecord(context.Background(), tt.collection, tt.rkey, tt.record) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("PutRecord() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Run check function if provided + if !tt.wantErr && tt.checkFunc != nil { + tt.checkFunc(t, result) + } + }) + } +} + +// TestGetRecord tests retrieving a record from ATProto +func TestGetRecord(t *testing.T) { + tests := []struct { + name string + collection string + rkey string + serverResponse string + serverStatus int + wantErr bool + wantNotFound bool + checkFunc func(*testing.T, *Record) + }{ + { + name: "successful get", + collection: ManifestCollection, + rkey: "abc123", + serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{"$type":"io.atcr.manifest","repository":"myapp"}}`, + serverStatus: http.StatusOK, + wantErr: false, + checkFunc: func(t *testing.T, r *Record) { + if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { + t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) + } + + var value map[string]interface{} + if err := json.Unmarshal(r.Value, &value); err != nil { + t.Errorf("Failed to unmarshal value: %v", err) + } + + if value["$type"] != ManifestCollection { + t.Errorf("value.$type = %v, want %v", value["$type"], ManifestCollection) + } + }, + }, + { + name: "record not found - 404", + collection: ManifestCollection, + rkey: "notfound", + serverResponse: ``, + serverStatus: http.StatusNotFound, + wantErr: true, + wantNotFound: true, + }, + { + name: "record not found - error message", + collection: ManifestCollection, + rkey: "notfound", + serverResponse: `{"error":"RecordNotFound","message":"Record not found"}`, + serverStatus: http.StatusBadRequest, + wantErr: true, + wantNotFound: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "GET" { + t.Errorf("Method = %v, want GET", r.Method) + } + + // Verify path + expectedPath := "/xrpc/com.atproto.repo.getRecord" + if r.URL.Path != expectedPath { + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) + } + + // Verify query parameters + query := r.URL.Query() + if query.Get("repo") != "did:plc:test123" { + t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) + } + if query.Get("collection") != tt.collection { + t.Errorf("collection = %v, want %v", query.Get("collection"), tt.collection) + } + if query.Get("rkey") != tt.rkey { + t.Errorf("rkey = %v, want %v", query.Get("rkey"), tt.rkey) + } + + // Send response + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + // Create client pointing to test server + client := NewClient(server.URL, "did:plc:test123", "test-token") + + // Call GetRecord + result, err := client.GetRecord(context.Background(), tt.collection, tt.rkey) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("GetRecord() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Check for ErrRecordNotFound + if tt.wantNotFound && err != ErrRecordNotFound { + t.Errorf("Expected ErrRecordNotFound, got %v", err) + } + + // Run check function if provided + if !tt.wantErr && tt.checkFunc != nil { + tt.checkFunc(t, result) + } + }) + } +} + +// TestDeleteRecord tests deleting a record from ATProto +func TestDeleteRecord(t *testing.T) { + tests := []struct { + name string + collection string + rkey string + serverResponse string + serverStatus int + wantErr bool + }{ + { + name: "successful delete", + collection: ManifestCollection, + rkey: "abc123", + serverResponse: `{}`, + serverStatus: http.StatusOK, + wantErr: false, + }, + { + name: "server error", + collection: ManifestCollection, + rkey: "abc123", + serverResponse: `{"error":"InvalidRequest"}`, + serverStatus: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != "POST" { + t.Errorf("Method = %v, want POST", r.Method) + } + + // Verify path + expectedPath := "/xrpc/com.atproto.repo.deleteRecord" + if r.URL.Path != expectedPath { + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) + } + + // Verify request body + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("Failed to decode request body: %v", err) + } + + if body["repo"] != "did:plc:test123" { + t.Errorf("repo = %v, want did:plc:test123", body["repo"]) + } + if body["collection"] != tt.collection { + t.Errorf("collection = %v, want %v", body["collection"], tt.collection) + } + if body["rkey"] != tt.rkey { + t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) + } + + // Send response + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + // Create client pointing to test server + client := NewClient(server.URL, "did:plc:test123", "test-token") + + // Call DeleteRecord + err := client.DeleteRecord(context.Background(), tt.collection, tt.rkey) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("DeleteRecord() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestListRecords tests listing records in a collection +func TestListRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + if query.Get("repo") != "did:plc:test123" { + t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) + } + if query.Get("collection") != ManifestCollection { + t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) + } + if query.Get("limit") != "10" { + t.Errorf("limit = %v, want 10", query.Get("limit")) + } + + // Send response + response := `{ + "records": [ + {"uri":"at://did:plc:test123/io.atcr.manifest/abc1","cid":"bafytest1","value":{"$type":"io.atcr.manifest"}}, + {"uri":"at://did:plc:test123/io.atcr.manifest/abc2","cid":"bafytest2","value":{"$type":"io.atcr.manifest"}} + ] + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + records, err := client.ListRecords(context.Background(), ManifestCollection, 10) + if err != nil { + t.Fatalf("ListRecords() error = %v", err) + } + + if len(records) != 2 { + t.Errorf("len(records) = %v, want 2", len(records)) + } + + if records[0].URI != "at://did:plc:test123/io.atcr.manifest/abc1" { + t.Errorf("records[0].URI = %v", records[0].URI) + } +} + +// TestUploadBlob tests uploading a blob to PDS +func TestUploadBlob(t *testing.T) { + blobData := []byte("test blob content") + mimeType := "application/octet-stream" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Method = %v, want POST", r.Method) + } + + if r.URL.Path != "/xrpc/com.atproto.repo.uploadBlob" { + t.Errorf("Path = %v, want /xrpc/com.atproto.repo.uploadBlob", r.URL.Path) + } + + if r.Header.Get("Content-Type") != mimeType { + t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType) + } + + // Send response + response := `{ + "blob": { + "$type": "blob", + "ref": {"$link": "bafytest123"}, + "mimeType": "application/octet-stream", + "size": 17 + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + blobRef, err := client.UploadBlob(context.Background(), blobData, mimeType) + if err != nil { + t.Fatalf("UploadBlob() error = %v", err) + } + + if blobRef.Type != "blob" { + t.Errorf("Type = %v, want blob", blobRef.Type) + } + + if blobRef.Ref.Link != "bafytest123" { + t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link) + } + + if blobRef.Size != 17 { + t.Errorf("Size = %v, want 17", blobRef.Size) + } +} + +// TestGetBlob tests downloading a blob from PDS +func TestGetBlob(t *testing.T) { + tests := []struct { + name string + cid string + serverResponse string + contentType string + wantData []byte + wantErr bool + }{ + { + name: "raw blob response", + cid: "bafytest123", + serverResponse: "test blob content", + contentType: "application/octet-stream", + wantData: []byte("test blob content"), + wantErr: false, + }, + { + name: "JSON-wrapped blob (Bluesky PDS format)", + cid: "bafytest123", + serverResponse: `"dGVzdCBibG9iIGNvbnRlbnQ="`, // base64 of "test blob content" + contentType: "application/json", + wantData: []byte("test blob content"), + wantErr: false, + }, + { + name: "blob not found", + cid: "notfound", + serverResponse: "", + contentType: "text/plain", + wantData: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + if query.Get("did") != "did:plc:test123" { + t.Errorf("did = %v, want did:plc:test123", query.Get("did")) + } + if query.Get("cid") != tt.cid { + t.Errorf("cid = %v, want %v", query.Get("cid"), tt.cid) + } + + // Send response + if tt.wantErr { + w.WriteHeader(http.StatusNotFound) + } else { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.serverResponse)) + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + data, err := client.GetBlob(context.Background(), tt.cid) + + if (err != nil) != tt.wantErr { + t.Errorf("GetBlob() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && string(data) != string(tt.wantData) { + t.Errorf("GetBlob() data = %v, want %v", string(data), string(tt.wantData)) + } + }) + } +} + +// TestBlobCDNURL tests CDN URL construction +func TestBlobCDNURL(t *testing.T) { + tests := []struct { + name string + didOrHandle string + cid string + want string + }{ + { + name: "with DID", + didOrHandle: "did:plc:alice123", + cid: "bafytest123", + want: "https://imgs.blue/did:plc:alice123/bafytest123", + }, + { + name: "with handle", + didOrHandle: "alice.bsky.social", + cid: "bafytest456", + want: "https://imgs.blue/alice.bsky.social/bafytest456", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BlobCDNURL(tt.didOrHandle, tt.cid) + if got != tt.want { + t.Errorf("BlobCDNURL() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestFetchDIDDocument tests fetching and parsing DID documents +func TestFetchDIDDocument(t *testing.T) { + tests := []struct { + name string + serverResponse string + serverStatus int + wantErr bool + checkFunc func(*testing.T, *DIDDocument) + }{ + { + name: "valid DID document", + serverResponse: `{ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": "did:web:example.com", + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": "https://pds.example.com" + } + ] + }`, + serverStatus: http.StatusOK, + wantErr: false, + checkFunc: func(t *testing.T, doc *DIDDocument) { + if doc.ID != "did:web:example.com" { + t.Errorf("ID = %v, want did:web:example.com", doc.ID) + } + if len(doc.Service) != 1 { + t.Fatalf("len(Service) = %v, want 1", len(doc.Service)) + } + if doc.Service[0].Type != "AtprotoPersonalDataServer" { + t.Errorf("Service[0].Type = %v", doc.Service[0].Type) + } + if doc.Service[0].ServiceEndpoint != "https://pds.example.com" { + t.Errorf("Service[0].ServiceEndpoint = %v", doc.Service[0].ServiceEndpoint) + } + }, + }, + { + name: "404 not found", + serverResponse: "", + serverStatus: http.StatusNotFound, + wantErr: true, + }, + { + name: "invalid JSON", + serverResponse: "not json", + serverStatus: http.StatusOK, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client := NewClient("https://pds.example.com", "did:plc:test123", "") + doc, err := client.FetchDIDDocument(context.Background(), server.URL) + + if (err != nil) != tt.wantErr { + t.Errorf("FetchDIDDocument() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.checkFunc != nil { + tt.checkFunc(t, doc) + } + }) + } +} + +// TestClientWithEmptyToken tests that client doesn't set auth header with empty token +func TestClientWithEmptyToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth != "" { + t.Errorf("Authorization header should not be set with empty token, got: %v", auth) + } + + response := `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + // Create client with empty token + client := NewClient(server.URL, "did:plc:test123", "") + + // Make request - should not include Authorization header + _, err := client.GetRecord(context.Background(), ManifestCollection, "abc123") + if err != nil { + t.Fatalf("GetRecord() error = %v", err) + } +} + +// TestListRecordsForRepo tests listing records for a specific repository +func TestListRecordsForRepo(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if query.Get("repo") != "did:plc:alice123" { + t.Errorf("repo = %v, want did:plc:alice123", query.Get("repo")) + } + if query.Get("collection") != ManifestCollection { + t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) + } + if query.Get("limit") != "50" { + t.Errorf("limit = %v, want 50", query.Get("limit")) + } + if query.Get("cursor") != "cursor123" { + t.Errorf("cursor = %v, want cursor123", query.Get("cursor")) + } + + response := `{ + "records": [ + {"uri":"at://did:plc:alice123/io.atcr.manifest/abc1","cid":"bafytest1","value":{}} + ], + "cursor": "nextcursor456" + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + records, cursor, err := client.ListRecordsForRepo(context.Background(), "did:plc:alice123", ManifestCollection, 50, "cursor123") + + if err != nil { + t.Fatalf("ListRecordsForRepo() error = %v", err) + } + + if len(records) != 1 { + t.Errorf("len(records) = %v, want 1", len(records)) + } + + if cursor != "nextcursor456" { + t.Errorf("cursor = %v, want nextcursor456", cursor) + } +} + +// TestContextCancellation tests that client respects context cancellation +func TestContextCancellation(t *testing.T) { + // Create a server that sleeps for a while + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + + // Create a context that gets canceled immediately + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Request should fail with context canceled error + _, err := client.GetRecord(ctx, ManifestCollection, "abc123") + if err == nil { + t.Error("Expected error due to context cancellation, got nil") + } +} diff --git a/pkg/atproto/endpoints.go b/pkg/atproto/endpoints.go new file mode 100644 index 0000000..47ac082 --- /dev/null +++ b/pkg/atproto/endpoints.go @@ -0,0 +1,139 @@ +// Package xrpc provides constants for XRPC endpoint paths used throughout ATCR. +// +// This package serves as a single source of truth for all XRPC endpoint URLs, +// preventing typos and making refactoring easier. All endpoint paths follow the +// XRPC/Lexicon naming convention: /xrpc/{namespace}.{method} +package atproto + +// Hold service multipart upload endpoints (io.atcr.hold.*) +// +// These endpoints handle OCI blob uploads to hold services (BYOS storage). +const ( + // HoldInitiateUpload starts a new multipart upload session. + // Method: POST + // Request: {"digest": "sha256:..."} + // Response: {"uploadId": "..."} + HoldInitiateUpload = "/xrpc/io.atcr.hold.initiateUpload" + + // HoldGetPartUploadUrl gets a presigned URL or endpoint info for uploading a specific part. + // Method: POST + // Request: {"uploadId": "...", "partNumber": 1} + // Response: {"url": "...", "method": "PUT", "headers": {...}} + HoldGetPartUploadUrl = "/xrpc/io.atcr.hold.getPartUploadUrl" + + // HoldUploadPart handles direct buffered part uploads (alternative to presigned URLs). + // Method: PUT + // Headers: X-Upload-Id, X-Part-Number + // Body: raw part data + // Response: {"etag": "..."} + HoldUploadPart = "/xrpc/io.atcr.hold.uploadPart" + + // HoldCompleteUpload finalizes a multipart upload and moves blob to final location. + // Method: POST + // Request: {"uploadId": "...", "digest": "sha256:...", "parts": [{...}]} + // Response: {"status": "completed", "digest": "..."} + HoldCompleteUpload = "/xrpc/io.atcr.hold.completeUpload" + + // HoldAbortUpload cancels a multipart upload and cleans up temporary data. + // Method: POST + // Request: {"uploadId": "..."} + // Response: {"status": "aborted"} + HoldAbortUpload = "/xrpc/io.atcr.hold.abortUpload" +) + +// Hold service crew management endpoints (io.atcr.hold.*) +// +// These endpoints manage access control for hold services via crew membership. +const ( + // HoldRequestCrew requests crew membership for a hold service. + // Method: POST + // Request: OAuth-authenticated request with DPoP + // Response: {"status": "pending"|"approved"} + HoldRequestCrew = "/xrpc/io.atcr.hold.requestCrew" + + // Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess" +) + +// ATProto sync endpoints (com.atproto.sync.*) +// +// Standard AT Protocol synchronization endpoints for PDS interoperability. +const ( + // SyncGetBlob retrieves a blob (or presigned URL) from a repository. + // Method: GET + // Query: did={did}&cid={cid}&method={GET|HEAD} + // Response: {"url": "..."} or blob data + SyncGetBlob = "/xrpc/com.atproto.sync.getBlob" + + // SyncGetRepo downloads a full repository or diff as a CAR file. + // Method: GET + // Query: did={did}&since={rev} + // Response: CAR file (application/vnd.ipld.car) + SyncGetRepo = "/xrpc/com.atproto.sync.getRepo" + + // SyncListRepos lists all repositories on a PDS. + // Method: GET + // Response: {"repos": [{...}]} + SyncListRepos = "/xrpc/com.atproto.sync.listRepos" + + // SyncSubscribeRepos subscribes to real-time repository events via WebSocket. + // Method: GET (WebSocket upgrade) + // Response: Stream of #commit events + SyncSubscribeRepos = "/xrpc/com.atproto.sync.subscribeRepos" + + // SyncRequestCrawl requests a relay to crawl a PDS. + // Method: POST + // Request: {"hostname": "hold01.atcr.io"} + // Response: {} + SyncRequestCrawl = "/xrpc/com.atproto.sync.requestCrawl" +) + +// ATProto server endpoints (com.atproto.server.*) +// +// Standard AT Protocol server management and authentication endpoints. +const ( + // ServerGetServiceAuth gets a service auth token for inter-service communication. + // Method: GET + // Query: aud={serviceDID}&lxm={lexicon} + // Response: {"token": "..."} + ServerGetServiceAuth = "/xrpc/com.atproto.server.getServiceAuth" + + // ServerDescribeServer returns server metadata and capabilities. + // Method: GET + // Response: {"did": "...", "availableUserDomains": [...]} + ServerDescribeServer = "/xrpc/com.atproto.server.describeServer" +) + +// ATProto repo endpoints (com.atproto.repo.*) +// +// Standard AT Protocol repository management endpoints. +const ( + // RepoDescribeRepo describes a repository's structure and metadata. + // Method: GET + // Query: repo={did} + // Response: {"did": "...", "handle": "...", "collections": [...]} + RepoDescribeRepo = "/xrpc/com.atproto.repo.describeRepo" + + // RepoDeleteRecord deletes a record from a repository. + // Method: POST + // Query: repo={did}&collection={collection}&rkey={key} + // Response: {} + RepoDeleteRecord = "/xrpc/com.atproto.repo.deleteRecord" + + // RepoUploadBlob uploads a blob to a repository (standard ATProto endpoint). + // Method: POST + // Body: blob data + // Response: {"blob": {"$type": "blob", "ref": {...}, "mimeType": "...", "size": ...}} + // Note: For OCI container layer uploads, ATCR uses io.atcr.hold.* multipart endpoints instead. + RepoUploadBlob = "/xrpc/com.atproto.repo.uploadBlob" +) + +// ATProto identity endpoints (com.atproto.identity.*) +// +// Standard AT Protocol identity resolution endpoints. +const ( + // IdentityResolveHandle resolves a handle to a DID. + // Method: GET + // Query: handle={handle} + // Response: {"did": "did:plc:..."} + IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle" +) diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 0dc88b4..2559d5c 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -68,11 +68,17 @@ type ManifestRecord struct { // SchemaVersion is the OCI schema version (typically 2) SchemaVersion int `json:"schemaVersion"` - // Config references the image configuration blob - Config BlobReference `json:"config"` + // Config references the image configuration blob (for image manifests) + // Nil for manifest lists/indexes + Config *BlobReference `json:"config,omitempty"` - // Layers references the filesystem layers - Layers []BlobReference `json:"layers"` + // Layers references the filesystem layers (for image manifests) + // Empty for manifest lists/indexes + Layers []BlobReference `json:"layers,omitempty"` + + // Manifests references other manifests (for manifest lists/indexes) + // Empty for image manifests + Manifests []ManifestReference `json:"manifests,omitempty"` // Annotations contains arbitrary metadata Annotations map[string]string `json:"annotations,omitempty"` @@ -106,14 +112,51 @@ type BlobReference struct { Annotations map[string]string `json:"annotations,omitempty"` } +// ManifestReference represents a reference to a manifest in a manifest list/index +type ManifestReference struct { + // MediaType of the referenced manifest + MediaType string `json:"mediaType"` + + // Digest is the content digest (e.g., "sha256:abc123...") + Digest string `json:"digest"` + + // Size in bytes + Size int64 `json:"size"` + + // Platform describes the platform/architecture this manifest is for + Platform *Platform `json:"platform,omitempty"` + + // Annotations for the manifest reference + Annotations map[string]string `json:"annotations,omitempty"` +} + +// Platform describes the platform (OS/architecture) for a manifest +type Platform struct { + // Architecture is the CPU architecture (e.g., "amd64", "arm64", "arm") + Architecture string `json:"architecture"` + + // OS is the operating system (e.g., "linux", "windows", "darwin") + OS string `json:"os"` + + // OSVersion is the optional OS version + OSVersion string `json:"os.version,omitempty"` + + // OSFeatures is an optional list of OS features + OSFeatures []string `json:"os.features,omitempty"` + + // Variant is the optional CPU variant (e.g., "v7" for ARM) + Variant string `json:"variant,omitempty"` +} + // NewManifestRecord creates a new manifest record from OCI manifest JSON func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) { // Parse the OCI manifest var ociData struct { SchemaVersion int `json:"schemaVersion"` MediaType string `json:"mediaType"` - Config json.RawMessage `json:"config"` - Layers []json.RawMessage `json:"layers"` + Config json.RawMessage `json:"config,omitempty"` + Layers []json.RawMessage `json:"layers,omitempty"` + Manifests []json.RawMessage `json:"manifests,omitempty"` Subject json.RawMessage `json:"subject,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` } @@ -122,6 +165,21 @@ func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest return nil, err } + // Detect manifest type based on media type + isManifestList := strings.Contains(ociData.MediaType, "manifest.list") || + strings.Contains(ociData.MediaType, "image.index") + + // Validate: must have either (config+layers) OR (manifests), never both + hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0 + hasIndexFields := len(ociData.Manifests) > 0 + + if hasImageFields && hasIndexFields { + return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)") + } + if !hasImageFields && !hasIndexFields { + return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)") + } + record := &ManifestRecord{ Type: ManifestCollection, Repository: repository, @@ -133,20 +191,34 @@ func NewManifestRecord(repository, digest string, ociManifest []byte) (*Manifest CreatedAt: time.Now(), } - // Parse config - if err := json.Unmarshal(ociData.Config, &record.Config); err != nil { - return nil, err - } + if isManifestList { + // Parse manifest list/index + record.Manifests = make([]ManifestReference, len(ociData.Manifests)) + for i, m := range ociData.Manifests { + if err := json.Unmarshal(m, &record.Manifests[i]); err != nil { + return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err) + } + } + } else { + // Parse image manifest + if len(ociData.Config) > 0 { + var config BlobReference + if err := json.Unmarshal(ociData.Config, &config); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + record.Config = &config + } - // Parse layers - record.Layers = make([]BlobReference, len(ociData.Layers)) - for i, layer := range ociData.Layers { - if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { - return nil, err + // Parse layers + record.Layers = make([]BlobReference, len(ociData.Layers)) + for i, layer := range ociData.Layers { + if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { + return nil, fmt.Errorf("failed to parse layer %d: %w", i, err) + } } } - // Parse subject if present + // Parse subject if present (works for both types) if len(ociData.Subject) > 0 { var subject BlobReference if err := json.Unmarshal(ociData.Subject, &subject); err != nil { diff --git a/pkg/atproto/lexicon_test.go b/pkg/atproto/lexicon_test.go index 7611ae5..0ae88c5 100644 --- a/pkg/atproto/lexicon_test.go +++ b/pkg/atproto/lexicon_test.go @@ -49,6 +49,55 @@ func TestNewManifestRecord(t *testing.T) { } }` + manifestList := `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:amd64manifest", + "size": 1000, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:arm64manifest", + "size": 1100, + "platform": { + "architecture": "arm64", + "os": "linux", + "variant": "v8" + } + } + ] + }` + + invalidBothFields := `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": "sha256:config123", + "size": 1234 + }, + "layers": [], + "manifests": [ + { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:amd64manifest", + "size": 1000 + } + ] + }` + + invalidNoFields := `{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json" + }` + tests := []struct { name string repository string @@ -137,6 +186,69 @@ func TestNewManifestRecord(t *testing.T) { ociManifest: `{"schemaVersion": 2, "mediaType": "test", "config": "not-an-object", "layers": []}`, wantErr: true, }, + { + name: "valid manifest list (multi-arch)", + repository: "myapp", + digest: "sha256:multiarch", + ociManifest: manifestList, + wantErr: false, + checkFunc: func(t *testing.T, record *ManifestRecord) { + if record.MediaType != "application/vnd.oci.image.index.v1+json" { + t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType) + } + if record.Config != nil { + t.Error("Config should be nil for manifest list") + } + if len(record.Layers) != 0 { + t.Errorf("Layers should be empty for manifest list, got %d", len(record.Layers)) + } + if len(record.Manifests) != 2 { + t.Fatalf("Manifests should have 2 entries, got %d", len(record.Manifests)) + } + + // Check first manifest (amd64) + if record.Manifests[0].Digest != "sha256:amd64manifest" { + t.Errorf("Manifests[0].Digest = %v, want sha256:amd64manifest", record.Manifests[0].Digest) + } + if record.Manifests[0].Size != 1000 { + t.Errorf("Manifests[0].Size = %v, want 1000", record.Manifests[0].Size) + } + if record.Manifests[0].Platform == nil { + t.Fatal("Manifests[0].Platform should not be nil") + } + if record.Manifests[0].Platform.Architecture != "amd64" { + t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture) + } + if record.Manifests[0].Platform.OS != "linux" { + t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS) + } + + // Check second manifest (arm64) + if record.Manifests[1].Digest != "sha256:arm64manifest" { + t.Errorf("Manifests[1].Digest = %v, want sha256:arm64manifest", record.Manifests[1].Digest) + } + if record.Manifests[1].Platform.Architecture != "arm64" { + t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture) + } + if record.Manifests[1].Platform.Variant != "v8" { + t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant) + } + }, + }, + { + name: "invalid: both image and index fields", + repository: "myapp", + digest: "sha256:invalid", + ociManifest: invalidBothFields, + wantErr: true, + }, + { + name: "invalid: neither image nor index fields", + repository: "myapp", + digest: "sha256:invalid", + ociManifest: invalidNoFields, + wantErr: true, + }, } for _, tt := range tests { diff --git a/pkg/atproto/manifest_store.go b/pkg/atproto/manifest_store.go index c4fb003..d884904 100644 --- a/pkg/atproto/manifest_store.go +++ b/pkg/atproto/manifest_store.go @@ -142,7 +142,11 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, manifestRecord.HoldEndpoint = s.holdEndpoint // Legacy reference (URL) for backward compat // Extract Dockerfile labels from config blob and add to annotations - if s.blobStore != nil && manifestRecord.Config.Digest != "" { + // Only for image manifests (not manifest lists which don't have config blobs) + isManifestList := strings.Contains(manifestRecord.MediaType, "manifest.list") || + strings.Contains(manifestRecord.MediaType, "image.index") + + if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" { labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest) if err != nil { // Log error but don't fail the push - labels are optional diff --git a/pkg/atproto/manifest_store_test.go b/pkg/atproto/manifest_store_test.go new file mode 100644 index 0000000..0b1c713 --- /dev/null +++ b/pkg/atproto/manifest_store_test.go @@ -0,0 +1,518 @@ +package atproto + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/distribution/distribution/v3" + "github.com/opencontainers/go-digest" +) + +// mockDatabaseMetrics is a mock implementation of DatabaseMetrics interface +type mockDatabaseMetrics struct { + pushCalls []pushCall + pullCalls []pullCall +} + +type pushCall struct { + did string + repository string +} + +type pullCall struct { + did string + repository string +} + +func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error { + m.pushCalls = append(m.pushCalls, pushCall{did: did, repository: repository}) + return nil +} + +func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error { + m.pullCalls = append(m.pullCalls, pullCall{did: did, repository: repository}) + return nil +} + +// mockBlobStore is a minimal mock of distribution.BlobStore for testing +type mockBlobStore struct { + blobs map[digest.Digest][]byte +} + +func newMockBlobStore() *mockBlobStore { + return &mockBlobStore{ + blobs: make(map[digest.Digest][]byte), + } +} + +func (m *mockBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) { + data, ok := m.blobs[dgst] + if !ok { + return nil, nil // Simplified: return nil for not found + } + return data, nil +} + +// Implement remaining methods to satisfy distribution.BlobStore interface +func (m *mockBlobStore) Put(ctx context.Context, mediaType string, p []byte) (distribution.Descriptor, error) { + dgst := digest.FromBytes(p) + m.blobs[dgst] = p + return distribution.Descriptor{Digest: dgst, Size: int64(len(p)), MediaType: mediaType}, nil +} + +func (m *mockBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { + return nil, nil // Not needed for current tests +} + +func (m *mockBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) { + return nil, nil // Not needed for current tests +} + +func (m *mockBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error { + return nil // Not needed for current tests +} + +func (m *mockBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) { + data, ok := m.blobs[dgst] + if !ok { + return distribution.Descriptor{}, distribution.ErrBlobUnknown + } + return distribution.Descriptor{Digest: dgst, Size: int64(len(data))}, nil +} + +func (m *mockBlobStore) Delete(ctx context.Context, dgst digest.Digest) error { + delete(m.blobs, dgst) + return nil +} + +func (m *mockBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) { + return nil, nil // Not needed for current tests +} + +// mockATProtoClient mocks the ATProto client for testing +type mockATProtoClient struct { + records map[string]map[string]interface{} // collection -> rkey -> record + blobs map[string][]byte // cid -> blob data +} + +func newMockATProtoClient() *mockATProtoClient { + return &mockATProtoClient{ + records: make(map[string]map[string]interface{}), + blobs: make(map[string][]byte), + } +} + +// TestDigestToRKey tests digest to record key conversion +func TestDigestToRKey(t *testing.T) { + tests := []struct { + name string + digest digest.Digest + want string + }{ + { + name: "sha256 digest", + digest: "sha256:abc123def456", + want: "abc123def456", + }, + { + name: "sha512 digest", + digest: "sha512:xyz789", + want: "xyz789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := digestToRKey(tt.digest) + if got != tt.want { + t.Errorf("digestToRKey() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestRepositoryTagToRKey tests repository+tag to record key conversion +func TestRepositoryTagToRKey(t *testing.T) { + tests := []struct { + name string + repository string + tag string + want string + }{ + { + name: "simple repo and tag", + repository: "myapp", + tag: "latest", + want: "myapp_latest", + }, + { + name: "repo with namespace", + repository: "org/myapp", + tag: "v1.0.0", + want: "org-myapp_v1.0.0", + }, + { + name: "tag with underscore", + repository: "myapp", + tag: "test_tag", + want: "myapp_test_tag", + }, + { + name: "deep namespace", + repository: "a/b/c/myapp", + tag: "prod", + want: "a-b-c-myapp_prod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := repositoryTagToRKey(tt.repository, tt.tag) + if got != tt.want { + t.Errorf("repositoryTagToRKey() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestRKeyToRepositoryTag tests converting record key back to repository and tag +func TestRKeyToRepositoryTag(t *testing.T) { + tests := []struct { + name string + rkey string + wantRepository string + wantTag string + }{ + { + name: "simple key", + rkey: "myapp_latest", + wantRepository: "myapp", + wantTag: "latest", + }, + { + name: "namespaced repo", + rkey: "org-myapp_v1.0.0", + wantRepository: "org/myapp", + wantTag: "v1.0.0", + }, + { + name: "tag with underscore (splits on last underscore)", + rkey: "myapp_test_tag", + wantRepository: "myapp_test", + wantTag: "tag", + }, + { + name: "deep namespace", + rkey: "a-b-c-myapp_prod", + wantRepository: "a/b/c/myapp", + wantTag: "prod", + }, + { + name: "no underscore - all tag", + rkey: "latest", + wantRepository: "", + wantTag: "latest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRepo, gotTag := RKeyToRepositoryTag(tt.rkey) + if gotRepo != tt.wantRepository { + t.Errorf("RKeyToRepositoryTag() repository = %v, want %v", gotRepo, tt.wantRepository) + } + if gotTag != tt.wantTag { + t.Errorf("RKeyToRepositoryTag() tag = %v, want %v", gotTag, tt.wantTag) + } + }) + } +} + +// TestRepositoryTagRoundTrip tests that converting to rkey and back preserves values +// Note: Tags with underscores cannot be perfectly round-tripped since we use underscore as separator +func TestRepositoryTagRoundTrip(t *testing.T) { + tests := []struct { + repository string + tag string + }{ + {"myapp", "latest"}, + {"org/myapp", "v1.0.0"}, + {"a/b/c/myapp", "prod"}, + // Note: Tags with underscores are excluded - they cannot round-trip correctly + // because underscore is used as the separator between repository and tag + } + + for _, tt := range tests { + t.Run(tt.repository+":"+tt.tag, func(t *testing.T) { + rkey := repositoryTagToRKey(tt.repository, tt.tag) + gotRepo, gotTag := RKeyToRepositoryTag(rkey) + + if gotRepo != tt.repository { + t.Errorf("Round trip failed: repository = %v, want %v", gotRepo, tt.repository) + } + if gotTag != tt.tag { + t.Errorf("Round trip failed: tag = %v, want %v", gotTag, tt.tag) + } + }) + } +} + +// TestNewManifestStore tests creating a new manifest store +func TestNewManifestStore(t *testing.T) { + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + blobStore := newMockBlobStore() + db := &mockDatabaseMetrics{} + + store := NewManifestStore( + client, + "myapp", + "https://hold.example.com", + "did:web:hold.example.com", + "did:plc:alice123", + blobStore, + db, + ) + + if store.repository != "myapp" { + t.Errorf("repository = %v, want myapp", store.repository) + } + if store.holdEndpoint != "https://hold.example.com" { + t.Errorf("holdEndpoint = %v, want https://hold.example.com", store.holdEndpoint) + } + if store.holdDID != "did:web:hold.example.com" { + t.Errorf("holdDID = %v, want did:web:hold.example.com", store.holdDID) + } + if store.did != "did:plc:alice123" { + t.Errorf("did = %v, want did:plc:alice123", store.did) + } +} + +// TestManifestStore_GetLastFetchedHoldDID tests tracking last fetched hold DID +func TestManifestStore_GetLastFetchedHoldDID(t *testing.T) { + tests := []struct { + name string + manifestHoldDID string + manifestHoldURL string + expectedLastFetched string + }{ + { + name: "prefers HoldDID", + manifestHoldDID: "did:web:hold01.atcr.io", + manifestHoldURL: "https://hold01.atcr.io", + expectedLastFetched: "did:web:hold01.atcr.io", + }, + { + name: "falls back to HoldEndpoint URL conversion", + manifestHoldDID: "", + manifestHoldURL: "https://hold02.atcr.io", + expectedLastFetched: "did:web:hold02.atcr.io", + }, + { + name: "empty hold references", + manifestHoldDID: "", + manifestHoldURL: "", + expectedLastFetched: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", nil, nil) + + // Simulate what happens in Get() when parsing a manifest record + var manifestRecord ManifestRecord + manifestRecord.HoldDID = tt.manifestHoldDID + manifestRecord.HoldEndpoint = tt.manifestHoldURL + + // Mimic the hold DID extraction logic from Get() + if manifestRecord.HoldDID != "" { + store.lastFetchedHoldDID = manifestRecord.HoldDID + } else if manifestRecord.HoldEndpoint != "" { + store.lastFetchedHoldDID = ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) + } + + got := store.GetLastFetchedHoldDID() + if got != tt.expectedLastFetched { + t.Errorf("GetLastFetchedHoldDID() = %v, want %v", got, tt.expectedLastFetched) + } + }) + } +} + +// TestRawManifest tests the rawManifest implementation +func TestRawManifest(t *testing.T) { + mediaType := "application/vnd.oci.image.manifest.v1+json" + payload := []byte(`{"schemaVersion":2}`) + + manifest := &rawManifest{ + mediaType: mediaType, + payload: payload, + } + + // Test Payload() + gotMediaType, gotPayload, err := manifest.Payload() + if err != nil { + t.Fatalf("Payload() error = %v", err) + } + + if gotMediaType != mediaType { + t.Errorf("Payload() mediaType = %v, want %v", gotMediaType, mediaType) + } + + if string(gotPayload) != string(payload) { + t.Errorf("Payload() payload = %v, want %v", string(gotPayload), string(payload)) + } + + // Test References() - should return nil for now + refs := manifest.References() + if refs != nil { + t.Errorf("References() = %v, want nil", refs) + } +} + +// TestExtractConfigLabels tests extracting labels from image config +func TestExtractConfigLabels(t *testing.T) { + // Create a mock config blob + configJSON := map[string]interface{}{ + "config": map[string]interface{}{ + "Labels": map[string]string{ + "org.opencontainers.image.version": "1.0.0", + "org.opencontainers.image.authors": "test@example.com", + "custom.label": "value", + }, + }, + } + configData, _ := json.Marshal(configJSON) + + // Create blob store with config + blobStore := newMockBlobStore() + configDigest := digest.FromBytes(configData) + blobStore.blobs[configDigest] = configData + + // Create manifest store + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) + + // Extract labels + labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) + if err != nil { + t.Fatalf("extractConfigLabels() error = %v", err) + } + + // Verify labels + expectedLabels := map[string]string{ + "org.opencontainers.image.version": "1.0.0", + "org.opencontainers.image.authors": "test@example.com", + "custom.label": "value", + } + + if len(labels) != len(expectedLabels) { + t.Errorf("len(labels) = %v, want %v", len(labels), len(expectedLabels)) + } + + for key, expectedValue := range expectedLabels { + if labels[key] != expectedValue { + t.Errorf("labels[%s] = %v, want %v", key, labels[key], expectedValue) + } + } +} + +// TestExtractConfigLabels_NoLabels tests handling config without labels +func TestExtractConfigLabels_NoLabels(t *testing.T) { + // Config without Labels field + configJSON := map[string]interface{}{ + "config": map[string]interface{}{}, + } + configData, _ := json.Marshal(configJSON) + + blobStore := newMockBlobStore() + configDigest := digest.FromBytes(configData) + blobStore.blobs[configDigest] = configData + + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) + + labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) + if err != nil { + t.Fatalf("extractConfigLabels() error = %v", err) + } + + // Should return empty map (or nil) + if labels != nil && len(labels) != 0 { + t.Errorf("extractConfigLabels() should return empty/nil for config without labels, got %v", labels) + } +} + +// TestExtractConfigLabels_InvalidDigest tests error handling for invalid digest +func TestExtractConfigLabels_InvalidDigest(t *testing.T) { + blobStore := newMockBlobStore() + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) + + _, err := store.extractConfigLabels(context.Background(), "invalid-digest") + if err == nil { + t.Error("extractConfigLabels() should return error for invalid digest") + } +} + +// TestExtractConfigLabels_InvalidJSON tests handling of malformed config JSON +func TestExtractConfigLabels_InvalidJSON(t *testing.T) { + // Invalid JSON + configData := []byte("not valid json") + + blobStore := newMockBlobStore() + configDigest := digest.FromBytes(configData) + blobStore.blobs[configDigest] = configData + + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) + + _, err := store.extractConfigLabels(context.Background(), configDigest.String()) + if err == nil { + t.Error("extractConfigLabels() should return error for invalid JSON") + } +} + +// TestManifestStore_WithMetrics tests that metrics are tracked +func TestManifestStore_WithMetrics(t *testing.T) { + db := &mockDatabaseMetrics{} + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore( + client, + "myapp", + "https://hold.example.com", + "did:web:hold.example.com", + "did:plc:alice123", + nil, + db, + ) + + if store.database != db { + t.Error("ManifestStore should store database reference") + } + + // Note: Actual metrics tracking happens in Put() and Get() which require + // full mock setup. The important thing is that the database is wired up. +} + +// TestManifestStore_WithoutMetrics tests that nil database is acceptable +func TestManifestStore_WithoutMetrics(t *testing.T) { + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewManifestStore( + client, + "myapp", + "https://hold.example.com", + "did:web:hold.example.com", + "did:plc:alice123", + nil, + nil, // nil database + ) + + if store.database != nil { + t.Error("ManifestStore should accept nil database") + } +} diff --git a/pkg/atproto/profile_test.go b/pkg/atproto/profile_test.go new file mode 100644 index 0000000..ed92251 --- /dev/null +++ b/pkg/atproto/profile_test.go @@ -0,0 +1,558 @@ +package atproto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// TestEnsureProfile_Create tests creating a new profile when one doesn't exist +func TestEnsureProfile_Create(t *testing.T) { + tests := []struct { + name string + defaultHoldDID string + wantNormalized string // Expected defaultHold value after normalization + }{ + { + name: "with DID", + defaultHoldDID: "did:web:hold01.atcr.io", + wantNormalized: "did:web:hold01.atcr.io", + }, + { + name: "with URL - should normalize to DID", + defaultHoldDID: "https://hold01.atcr.io", + wantNormalized: "did:web:hold01.atcr.io", + }, + { + name: "empty default hold", + defaultHoldDID: "", + wantNormalized: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var createdProfile *SailorProfileRecord + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // First request: GetRecord (should 404) + if r.Method == "GET" { + w.WriteHeader(http.StatusNotFound) + return + } + + // Second request: PutRecord (create profile) + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + + // Verify profile data + recordData := body["record"].(map[string]interface{}) + if recordData["$type"] != SailorProfileCollection { + t.Errorf("$type = %v, want %v", recordData["$type"], SailorProfileCollection) + } + + // Check defaultHold normalization + defaultHold := recordData["defaultHold"] + // Handle empty string (may be nil in JSON) + defaultHoldStr := "" + if defaultHold != nil { + defaultHoldStr = defaultHold.(string) + } + if defaultHoldStr != tt.wantNormalized { + t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized) + } + + // Store for later verification + profileBytes, _ := json.Marshal(recordData) + json.Unmarshal(profileBytes, &createdProfile) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + err := EnsureProfile(context.Background(), client, tt.defaultHoldDID) + + if err != nil { + t.Fatalf("EnsureProfile() error = %v", err) + } + + // Verify created profile + if createdProfile == nil { + t.Fatal("Profile was not created") + } + + if createdProfile.Type != SailorProfileCollection { + t.Errorf("Type = %v, want %v", createdProfile.Type, SailorProfileCollection) + } + + if createdProfile.DefaultHold != tt.wantNormalized { + t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized) + } + }) + } +} + +// TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles +func TestEnsureProfile_Exists(t *testing.T) { + putRecordCalled := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GetRecord: profile exists + if r.Method == "GET" { + response := `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "cid": "bafytest", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": "did:web:hold01.atcr.io", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + return + } + + // PutRecord: should not be called + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + putRecordCalled = true + t.Error("PutRecord should not be called when profile exists") + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") + + if err != nil { + t.Fatalf("EnsureProfile() error = %v", err) + } + + if putRecordCalled { + t.Error("PutRecord was called when profile already exists") + } +} + +// TestGetProfile tests retrieving a user's profile +func TestGetProfile(t *testing.T) { + tests := []struct { + name string + serverResponse string + serverStatus int + wantProfile *SailorProfileRecord + wantNil bool + wantErr bool + expectMigration bool // Whether URL-to-DID migration should happen + originalHoldURL string + expectedHoldDID string + }{ + { + name: "profile with DID (no migration needed)", + serverResponse: `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": "did:web:hold01.atcr.io", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }`, + serverStatus: http.StatusOK, + wantNil: false, + wantErr: false, + expectMigration: false, + expectedHoldDID: "did:web:hold01.atcr.io", + }, + { + name: "profile with URL (migration needed)", + serverResponse: `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": "https://hold01.atcr.io", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }`, + serverStatus: http.StatusOK, + wantNil: false, + wantErr: false, + expectMigration: true, + originalHoldURL: "https://hold01.atcr.io", + expectedHoldDID: "did:web:hold01.atcr.io", + }, + { + name: "profile doesn't exist - return nil", + serverResponse: "", + serverStatus: http.StatusNotFound, + wantNil: true, + wantErr: false, + expectMigration: false, + }, + { + name: "server error", + serverResponse: `{"error":"InternalServerError"}`, + serverStatus: http.StatusInternalServerError, + wantNil: false, + wantErr: true, + expectMigration: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear migration locks before each test + migrationLocks = sync.Map{} + + putRecordCalled := false + var migrationRequest map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GetRecord + if r.Method == "GET" { + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + return + } + + // PutRecord (migration) + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + putRecordCalled = true + json.NewDecoder(r.Body).Decode(&migrationRequest) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + profile, err := GetProfile(context.Background(), client) + + if (err != nil) != tt.wantErr { + t.Errorf("GetProfile() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantNil { + if profile != nil { + t.Errorf("GetProfile() = %v, want nil", profile) + } + return + } + + if !tt.wantErr { + if profile == nil { + t.Fatal("GetProfile() returned nil, want profile") + } + + // Check that defaultHold is migrated to DID in returned profile + if profile.DefaultHold != tt.expectedHoldDID { + t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID) + } + + if tt.expectMigration { + // Give goroutine time to execute + time.Sleep(50 * time.Millisecond) + + if !putRecordCalled { + t.Error("Expected migration PutRecord to be called") + } + + if migrationRequest != nil { + recordData := migrationRequest["record"].(map[string]interface{}) + migratedHold := recordData["defaultHold"] + if migratedHold != tt.expectedHoldDID { + t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID) + } + } + } + } + }) + } +} + +// TestGetProfile_MigrationLocking tests that concurrent migrations don't happen +func TestGetProfile_MigrationLocking(t *testing.T) { + // Clear migration locks + migrationLocks = sync.Map{} + + putRecordCount := 0 + var mu sync.Mutex + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GetRecord - return profile with URL + if r.Method == "GET" { + response := `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": "https://hold01.atcr.io", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + return + } + + // PutRecord - count migrations + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + mu.Lock() + putRecordCount++ + mu.Unlock() + + // Add small delay to ensure concurrent requests + time.Sleep(10 * time.Millisecond) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + + // Make 5 concurrent GetProfile calls + var wg sync.WaitGroup + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := GetProfile(context.Background(), client) + if err != nil { + t.Errorf("GetProfile() error = %v", err) + } + }() + } + + wg.Wait() + + // Give migrations time to complete + time.Sleep(200 * time.Millisecond) + + // Only one migration should have been persisted due to locking + mu.Lock() + count := putRecordCount + mu.Unlock() + + if count != 1 { + t.Errorf("PutRecord called %d times, want 1 (locking should prevent concurrent migrations)", count) + } +} + +// TestUpdateProfile tests updating a user's profile +func TestUpdateProfile(t *testing.T) { + tests := []struct { + name string + profile *SailorProfileRecord + wantNormalized string // Expected defaultHold after normalization + wantErr bool + }{ + { + name: "update with DID", + profile: &SailorProfileRecord{ + Type: SailorProfileCollection, + DefaultHold: "did:web:hold02.atcr.io", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + wantNormalized: "did:web:hold02.atcr.io", + wantErr: false, + }, + { + name: "update with URL - should normalize", + profile: &SailorProfileRecord{ + Type: SailorProfileCollection, + DefaultHold: "https://hold02.atcr.io", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + wantNormalized: "did:web:hold02.atcr.io", + wantErr: false, + }, + { + name: "clear default hold", + profile: &SailorProfileRecord{ + Type: SailorProfileCollection, + DefaultHold: "", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + wantNormalized: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sentProfile map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + sentProfile = body + + // Verify rkey is "self" + if body["rkey"] != ProfileRKey { + t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) + return + } + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + err := UpdateProfile(context.Background(), client, tt.profile) + + if (err != nil) != tt.wantErr { + t.Errorf("UpdateProfile() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + // Verify normalization happened + recordData := sentProfile["record"].(map[string]interface{}) + defaultHold := recordData["defaultHold"] + // Handle empty string (may be nil in JSON) + defaultHoldStr := "" + if defaultHold != nil { + defaultHoldStr = defaultHold.(string) + } + if defaultHoldStr != tt.wantNormalized { + t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized) + } + + // Verify normalization also updated the profile object + if tt.profile.DefaultHold != tt.wantNormalized { + t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized) + } + } + }) + } +} + +// TestProfileRKey tests that profile record key is always "self" +func TestProfileRKey(t *testing.T) { + if ProfileRKey != "self" { + t.Errorf("ProfileRKey = %v, want self", ProfileRKey) + } +} + +// TestEnsureProfile_Error tests error handling during profile creation +func TestEnsureProfile_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GetRecord: profile doesn't exist + if r.Method == "GET" { + w.WriteHeader(http.StatusNotFound) + return + } + + // PutRecord: fail with server error + if r.Method == "POST" { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"InternalServerError"}`)) + return + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") + + if err == nil { + t.Error("EnsureProfile() should return error when PutRecord fails") + } +} + +// TestGetProfile_InvalidJSON tests handling of invalid profile JSON +func TestGetProfile_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": "not-valid-json-object" + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + _, err := GetProfile(context.Background(), client) + + if err == nil { + t.Error("GetProfile() should return error for invalid JSON") + } +} + +// TestGetProfile_EmptyDefaultHold tests profile with empty defaultHold +func TestGetProfile_EmptyDefaultHold(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", + "value": { + "$type": "io.atcr.sailor.profile", + "defaultHold": "", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T00:00:00Z" + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + profile, err := GetProfile(context.Background(), client) + + if err != nil { + t.Fatalf("GetProfile() error = %v", err) + } + + if profile.DefaultHold != "" { + t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold) + } +} + +// TestUpdateProfile_ServerError tests error handling in UpdateProfile +func TestUpdateProfile_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"InternalServerError"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + profile := &SailorProfileRecord{ + Type: SailorProfileCollection, + DefaultHold: "did:web:hold01.atcr.io", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := UpdateProfile(context.Background(), client, profile) + + if err == nil { + t.Error("UpdateProfile() should return error when server fails") + } +} diff --git a/pkg/atproto/tag_store_test.go b/pkg/atproto/tag_store_test.go new file mode 100644 index 0000000..fbefd71 --- /dev/null +++ b/pkg/atproto/tag_store_test.go @@ -0,0 +1,645 @@ +package atproto + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/distribution/distribution/v3" + "github.com/opencontainers/go-digest" +) + +// TestNewTagStore tests creating a new tag store +func TestNewTagStore(t *testing.T) { + client := NewClient("https://pds.example.com", "did:plc:test123", "token") + store := NewTagStore(client, "myapp") + + if store.repository != "myapp" { + t.Errorf("repository = %v, want myapp", store.repository) + } + if store.client == nil { + t.Error("client should not be nil") + } +} + +// TestTagStore_Get tests retrieving a tag +func TestTagStore_Get(t *testing.T) { + tests := []struct { + name string + tag string + serverResponse string + serverStatus int + wantErr bool + wantDigest string + }{ + { + name: "existing tag", + tag: "latest", + serverResponse: `{ + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "cid": "bafytest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "updatedAt": "2025-01-01T00:00:00Z" + } + }`, + serverStatus: http.StatusOK, + wantErr: false, + wantDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + }, + { + name: "tag not found", + tag: "notfound", + serverResponse: "", + serverStatus: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + rkey := repositoryTagToRKey("myapp", tt.tag) + if query.Get("rkey") != rkey { + t.Errorf("rkey = %v, want %v", query.Get("rkey"), rkey) + } + if query.Get("collection") != TagCollection { + t.Errorf("collection = %v, want %v", query.Get("collection"), TagCollection) + } + + w.WriteHeader(tt.serverStatus) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + desc, err := store.Get(context.Background(), tt.tag) + + if (err != nil) != tt.wantErr { + t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr { + if desc.Digest.String() != tt.wantDigest { + t.Errorf("Digest = %v, want %v", desc.Digest.String(), tt.wantDigest) + } + if desc.MediaType != "application/vnd.oci.image.manifest.v1+json" { + t.Errorf("MediaType = %v", desc.MediaType) + } + } + }) + } +} + +// TestTagStore_Get_InvalidDigest tests error handling for invalid digest in tag record +func TestTagStore_Get_InvalidDigest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "invalid-digest-format" + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + _, err := store.Get(context.Background(), "latest") + if err == nil { + t.Error("Get() should return error for invalid digest format") + } +} + +// TestTagStore_Tag tests creating/updating a tag +func TestTagStore_Tag(t *testing.T) { + tests := []struct { + name string + tag string + digest digest.Digest + serverStatus int + wantErr bool + }{ + { + name: "create new tag", + tag: "v1.0.0", + digest: "sha256:abc123def456", + serverStatus: http.StatusOK, + wantErr: false, + }, + { + name: "update existing tag", + tag: "latest", + digest: "sha256:newdigest789", + serverStatus: http.StatusOK, + wantErr: false, + }, + { + name: "server error", + tag: "failed", + digest: "sha256:test", + serverStatus: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sentTagRecord *TagRecord + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Method = %v, want POST", r.Method) + } + + // Parse request body + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + + // Verify rkey + expectedRKey := repositoryTagToRKey("myapp", tt.tag) + if body["rkey"] != expectedRKey { + t.Errorf("rkey = %v, want %v", body["rkey"], expectedRKey) + } + + // Verify collection + if body["collection"] != TagCollection { + t.Errorf("collection = %v, want %v", body["collection"], TagCollection) + } + + // Parse and verify tag record + recordData := body["record"].(map[string]interface{}) + recordBytes, _ := json.Marshal(recordData) + var tagRecord TagRecord + json.Unmarshal(recordBytes, &tagRecord) + sentTagRecord = &tagRecord + + w.WriteHeader(tt.serverStatus) + if tt.serverStatus == http.StatusOK { + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.tag/` + expectedRKey + `","cid":"bafytest"}`)) + } else { + w.Write([]byte(`{"error":"ServerError"}`)) + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + desc := distribution.Descriptor{ + Digest: tt.digest, + MediaType: "application/vnd.oci.image.manifest.v1+json", + } + + err := store.Tag(context.Background(), tt.tag, desc) + + if (err != nil) != tt.wantErr { + t.Errorf("Tag() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && sentTagRecord != nil { + // Verify the tag record + if sentTagRecord.Type != TagCollection { + t.Errorf("Type = %v, want %v", sentTagRecord.Type, TagCollection) + } + if sentTagRecord.Repository != "myapp" { + t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository) + } + if sentTagRecord.Tag != tt.tag { + t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag) + } + if sentTagRecord.ManifestDigest != tt.digest.String() { + t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String()) + } + } + }) + } +} + +// TestTagStore_Untag tests removing a tag +func TestTagStore_Untag(t *testing.T) { + tests := []struct { + name string + tag string + serverStatus int + wantErr bool + }{ + { + name: "successful delete", + tag: "old-tag", + serverStatus: http.StatusOK, + wantErr: false, + }, + { + name: "server error", + tag: "tag", + serverStatus: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify it's a DELETE request (via deleteRecord) + if r.Method != "POST" { + t.Errorf("Method = %v, want POST", r.Method) + } + + // Parse body to verify delete parameters + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + + expectedRKey := repositoryTagToRKey("myapp", tt.tag) + if body["rkey"] != expectedRKey { + t.Errorf("rkey = %v, want %v", body["rkey"], expectedRKey) + } + + w.WriteHeader(tt.serverStatus) + if tt.serverStatus == http.StatusOK { + w.Write([]byte(`{}`)) + } else { + w.Write([]byte(`{"error":"ServerError"}`)) + } + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + err := store.Untag(context.Background(), tt.tag) + + if (err != nil) != tt.wantErr { + t.Errorf("Untag() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// TestTagStore_All tests listing all tags for a repository +func TestTagStore_All(t *testing.T) { + tests := []struct { + name string + serverResponse string + wantTags []string + }{ + { + name: "multiple tags for repository", + serverResponse: `{ + "records": [ + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:abc123" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "v1.0.0", + "manifestDigest": "sha256:def456" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/apper_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "apper", + "tag": "latest", + "manifestDigest": "sha256:xyz789" + } + } + ] + }`, + wantTags: []string{"latest", "v1.0.0"}, + }, + { + name: "no tags", + serverResponse: `{ + "records": [] + }`, + wantTags: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify query parameters + query := r.URL.Query() + if query.Get("collection") != TagCollection { + t.Errorf("collection = %v, want %v", query.Get("collection"), TagCollection) + } + if query.Get("limit") != "100" { + t.Errorf("limit = %v, want 100", query.Get("limit")) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + tags, err := store.All(context.Background()) + if err != nil { + t.Fatalf("All() error = %v", err) + } + + // Sort both slices for comparison (order doesn't matter) + if len(tags) != len(tt.wantTags) { + t.Errorf("len(tags) = %v, want %v", len(tags), len(tt.wantTags)) + } + + // Check that all expected tags are present + tagMap := make(map[string]bool) + for _, tag := range tags { + tagMap[tag] = true + } + + for _, wantTag := range tt.wantTags { + if !tagMap[wantTag] { + t.Errorf("Missing expected tag: %v", wantTag) + } + } + }) + } +} + +// TestTagStore_All_SkipsInvalidRecords tests that invalid records are skipped +func TestTagStore_All_SkipsInvalidRecords(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "records": [ + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:abc123" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/invalid", + "value": "invalid-json-structure" + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "v1.0.0", + "manifestDigest": "sha256:def456" + } + } + ] + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + tags, err := store.All(context.Background()) + if err != nil { + t.Fatalf("All() error = %v", err) + } + + // Should return 2 valid tags (invalid record skipped) + if len(tags) != 2 { + t.Errorf("len(tags) = %v, want 2 (invalid record should be skipped)", len(tags)) + } +} + +// TestTagStore_Lookup tests finding tags for a specific digest +func TestTagStore_Lookup(t *testing.T) { + targetDigest := "sha256:abc123" + + tests := []struct { + name string + digest digest.Digest + serverResponse string + wantTags []string + }{ + { + name: "multiple tags point to same digest", + digest: digest.Digest(targetDigest), + serverResponse: `{ + "records": [ + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:abc123" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "v1.0.0", + "manifestDigest": "sha256:abc123" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_old", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "old", + "manifestDigest": "sha256:differentdigest" + } + } + ] + }`, + wantTags: []string{"latest", "v1.0.0"}, + }, + { + name: "no tags for digest", + digest: "sha256:notfound", + serverResponse: `{ + "records": [ + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:different" + } + } + ] + }`, + wantTags: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + desc := distribution.Descriptor{ + Digest: tt.digest, + } + + tags, err := store.Lookup(context.Background(), desc) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + + if len(tags) != len(tt.wantTags) { + t.Errorf("len(tags) = %v, want %v", len(tags), len(tt.wantTags)) + } + + // Check that all expected tags are present + tagMap := make(map[string]bool) + for _, tag := range tags { + tagMap[tag] = true + } + + for _, wantTag := range tt.wantTags { + if !tagMap[wantTag] { + t.Errorf("Missing expected tag: %v", wantTag) + } + } + }) + } +} + +// TestTagStore_Lookup_FiltersByRepository tests that Lookup only returns tags for the correct repository +func TestTagStore_Lookup_FiltersByRepository(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return tags from multiple repositories with same digest + response := `{ + "records": [ + { + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + "manifestDigest": "sha256:abc123" + } + }, + { + "uri": "at://did:plc:test123/io.atcr.tag/otherapp_latest", + "value": { + "$type": "io.atcr.tag", + "repository": "otherapp", + "tag": "latest", + "manifestDigest": "sha256:abc123" + } + } + ] + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") // Looking for "myapp" tags only + + desc := distribution.Descriptor{ + Digest: "sha256:abc123", + } + + tags, err := store.Lookup(context.Background(), desc) + if err != nil { + t.Fatalf("Lookup() error = %v", err) + } + + // Should only return "latest" from "myapp", not from "otherapp" + if len(tags) != 1 { + t.Errorf("len(tags) = %v, want 1 (should filter by repository)", len(tags)) + } + + if len(tags) > 0 && tags[0] != "latest" { + t.Errorf("tags[0] = %v, want latest", tags[0]) + } +} + +// TestTagStore_ListRecordsError tests error handling when ListRecords fails +func TestTagStore_ListRecordsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"ServerError"}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + // Test All() + _, err := store.All(context.Background()) + if err == nil { + t.Error("All() should return error when ListRecords fails") + } + + // Test Lookup() + desc := distribution.Descriptor{Digest: "sha256:abc123"} + _, err = store.Lookup(context.Background(), desc) + if err == nil { + t.Error("Lookup() should return error when ListRecords fails") + } +} + +// TestTagStore_GetErrorTypes tests that Get returns correct error type +func TestTagStore_GetErrorTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + _, err := store.Get(context.Background(), "notfound") + + // Should return distribution.ErrTagUnknown + if err == nil { + t.Error("Get() should return error for non-existent tag") + } + + // Check if it's the right error type + if !strings.Contains(err.Error(), "unknown tag") && !strings.Contains(err.Error(), "TagUnknown") { + t.Errorf("Get() should return ErrTagUnknown, got: %v", err) + } +} diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index 40f7641..1c80c54 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -124,8 +124,14 @@ func GetDefaultScopes(did string) []string { return []string{ "atproto", "transition:generic", + // Image manifest types (single-arch) "blob:application/vnd.oci.image.manifest.v1+json", "blob:application/vnd.docker.distribution.manifest.v2+json", + // Manifest list/index types (multi-arch) + "blob:application/vnd.oci.image.index.v1+json", + "blob:application/vnd.docker.distribution.manifest.list.v2+json", + // OCI artifact manifests (for cosign signatures, SBOMs, attestations) + "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did), fmt.Sprintf("repo:%s", atproto.ManifestCollection), fmt.Sprintf("repo:%s", atproto.TagCollection), diff --git a/pkg/hold/config_test.go b/pkg/hold/config_test.go index 8cb4f2f..09f2b7b 100644 --- a/pkg/hold/config_test.go +++ b/pkg/hold/config_test.go @@ -36,16 +36,16 @@ func setupEnv(t *testing.T, vars map[string]string) func() { func TestLoadConfigFromEnv_Success(t *testing.T) { cleanup := setupEnv(t, map[string]string{ - "HOLD_PUBLIC_URL": "https://hold.example.com", - "HOLD_SERVER_ADDR": ":9000", - "HOLD_PUBLIC": "true", - "TEST_MODE": "true", - "HOLD_OWNER": "did:plc:owner123", + "HOLD_PUBLIC_URL": "https://hold.example.com", + "HOLD_SERVER_ADDR": ":9000", + "HOLD_PUBLIC": "true", + "TEST_MODE": "true", + "HOLD_OWNER": "did:plc:owner123", "HOLD_ALLOW_ALL_CREW": "true", - "STORAGE_DRIVER": "filesystem", - "STORAGE_ROOT_DIR": "/tmp/test-storage", - "HOLD_DATABASE_DIR": "/tmp/test-db", - "HOLD_KEY_PATH": "/tmp/test-key.pem", + "STORAGE_DRIVER": "filesystem", + "STORAGE_ROOT_DIR": "/tmp/test-storage", + "HOLD_DATABASE_DIR": "/tmp/test-db", + "HOLD_KEY_PATH": "/tmp/test-key.pem", }) defer cleanup() diff --git a/pkg/hold/oci/multipart.go b/pkg/hold/oci/multipart.go index 0810007..ada56e1 100644 --- a/pkg/hold/oci/multipart.go +++ b/pkg/hold/oci/multipart.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "atcr.io/pkg/atproto" "github.com/aws/aws-sdk-go/service/s3" "github.com/google/uuid" ) @@ -292,7 +293,7 @@ func (h *XRPCHandler) GetPartUploadURL(ctx context.Context, uploadID string, par // Buffered mode: return XRPC endpoint with headers return &PartUploadInfo{ - URL: fmt.Sprintf("%s/xrpc/io.atcr.hold.uploadPart", h.pds.PublicURL), + URL: fmt.Sprintf("%s%s", h.pds.PublicURL, atproto.HoldUploadPart), Method: "PUT", Headers: map[string]string{ "X-Upload-Id": uploadID, diff --git a/pkg/hold/oci/xrpc.go b/pkg/hold/oci/xrpc.go index b02618c..27ae21b 100644 --- a/pkg/hold/oci/xrpc.go +++ b/pkg/hold/oci/xrpc.go @@ -6,8 +6,8 @@ import ( "net/http" "strconv" + "atcr.io/pkg/atproto" "atcr.io/pkg/hold/pds" - "atcr.io/pkg/s3" storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" "github.com/go-chi/chi/v5" @@ -41,11 +41,11 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) { r.Group(func(r chi.Router) { r.Use(h.requireBlobWriteAccess) - r.Post("/xrpc/io.atcr.hold.initiateUpload", h.HandleInitiateUpload) - r.Post("/xrpc/io.atcr.hold.getPartUploadUrl", h.HandleGetPartUploadUrl) - r.Put("/xrpc/io.atcr.hold.uploadPart", h.HandleUploadPart) - r.Post("/xrpc/io.atcr.hold.completeUpload", h.HandleCompleteUpload) - r.Post("/xrpc/io.atcr.hold.abortUpload", h.HandleAbortUpload) + r.Post(atproto.HoldInitiateUpload, h.HandleInitiateUpload) + r.Post(atproto.HoldGetPartUploadUrl, h.HandleGetPartUploadUrl) + r.Put(atproto.HoldUploadPart, h.HandleUploadPart) + r.Post(atproto.HoldCompleteUpload, h.HandleCompleteUpload) + r.Post(atproto.HoldAbortUpload, h.HandleAbortUpload) }) }