try and support multi-arch manifest types. add more unit tests. add scope for oras blobs for future proofing

This commit is contained in:
Evan Jarrett
2025-10-19 22:26:47 -05:00
parent 7228b532ba
commit 965e73881b
29 changed files with 4056 additions and 115 deletions
+12 -2
View File
@@ -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)
+71 -3
View File
@@ -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)"
}
}
}
}
}
@@ -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);
+37
View File
@@ -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
}
+361 -2
View File
@@ -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
+15
View File
@@ -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,
+37
View File
@@ -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)
+47 -6
View File
@@ -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 {
+26 -8
View File
@@ -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,
+58 -16
View File
@@ -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
}
}
}
+58 -16
View File
@@ -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
}
}
}
+55
View File
@@ -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;
+5 -4
View File
@@ -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
@@ -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(`<?xml version="1.0"?><Error><Code>SignatureDoesNotMatch</Code></Error>`))
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!")
}
})
}
}
+68 -25
View File
@@ -64,11 +64,11 @@
<!-- Pull Command -->
<div class="pull-command-section">
<h3>Pull this image</h3>
{{ if .Repository.Tags }}
{{ $firstTag := index .Repository.Tags 0 }}
{{ if .Tags }}
{{ $firstTag := index .Tags 0 }}
<div class="push-command">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}')">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}')">
Copy
</button>
</div>
@@ -86,21 +86,26 @@
<!-- Tags Section -->
<div class="repo-section">
<h2>Tags</h2>
{{ if .Repository.Tags }}
{{ if .Tags }}
<div class="tags-list">
{{ range .Repository.Tags }}
<div class="tag-item" id="tag-{{ .Tag }}">
{{ range .Tags }}
<div class="tag-item" id="tag-{{ .Tag.Tag }}">
<div class="tag-item-header">
<span class="tag-name-large">{{ .Tag }}</span>
<div>
<span class="tag-name-large">{{ .Tag.Tag }}</span>
{{ if .IsMultiArch }}
<span class="badge-multi">Multi-arch</span>
{{ end }}
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .CreatedAt }}
<time class="tag-timestamp" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Tag.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="delete-btn"
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag }}"
hx-confirm="Delete tag {{ .Tag }}?"
hx-target="#tag-{{ .Tag }}"
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag.Tag }}"
hx-confirm="Delete tag {{ .Tag.Tag }}?"
hx-target="#tag-{{ .Tag.Tag }}"
hx-swap="outerHTML">
🗑️
</button>
@@ -108,11 +113,20 @@
</div>
</div>
<div class="tag-item-details">
<code class="digest">{{ .Digest }}</code>
<div style="display: flex; justify-content: space-between; align-items: center;">
<code class="digest">{{ .Tag.Digest }}</code>
{{ if .Platforms }}
<div class="platforms-inline">
{{ range .Platforms }}
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ end }}
</div>
</div>
<div class="push-command">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}')">
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}</code>
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}')">
Copy
</button>
</div>
@@ -127,19 +141,48 @@
<!-- Manifests Section -->
<div class="repo-section">
<h2>Manifests</h2>
{{ if .Repository.Manifests }}
{{ if .Manifests }}
<div class="manifests-list">
{{ range .Repository.Manifests }}
<div class="manifest-item">
{{ range .Manifests }}
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}">
<div class="manifest-item-header">
<code class="manifest-digest">{{ .Digest }}</code>
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .CreatedAt }}
</time>
<div>
{{ if .IsManifestList }}
<span class="manifest-type">📦 Multi-arch</span>
{{ else }}
<span class="manifest-type">📄 Image</span>
{{ end }}
<code class="manifest-digest">{{ .Manifest.Digest }}</code>
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
{{ timeAgo .Manifest.CreatedAt }}
</time>
{{ if $.IsOwner }}
<button class="delete-btn"
hx-delete="/api/images/{{ $.Repository.Name }}/manifests/{{ .Manifest.Digest }}"
hx-confirm="Delete manifest {{ .Manifest.Digest }}? This cannot be undone."
hx-target="#manifest-{{ sanitizeID .Manifest.Digest }}"
hx-swap="outerHTML">
🗑️
</button>
{{ end }}
</div>
</div>
<div class="manifest-item-details">
<span class="manifest-detail-label">Storage:</span>
<span>{{ .HoldEndpoint }}</span>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
{{ if .Tags }}
<span class="manifest-detail-label">Tags:</span>
{{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }}
{{ else }}
<span class="text-muted">(untagged)</span>
{{ end }}
</div>
{{ if .IsManifestList }}
<span class="platform-count">{{ .PlatformCount }} platforms</span>
{{ end }}
</div>
</div>
</div>
{{ end }}
+7
View File
@@ -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)
+76
View File
@@ -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 {
+693
View File
@@ -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")
}
}
+139
View File
@@ -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"
)
+88 -16
View File
@@ -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 {
+112
View File
@@ -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 {
+5 -1
View File
@@ -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
+518
View File
@@ -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")
}
}
+558
View File
@@ -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")
}
}
+645
View File
@@ -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)
}
}
+6
View File
@@ -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),
+9 -9
View File
@@ -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()
+2 -1
View File
@@ -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,
+6 -6
View File
@@ -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)
})
}