mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-07 18:56:55 +00:00
try and support multi-arch manifest types. add more unit tests. add scope for oras blobs for future proofing
This commit is contained in:
+12
-2
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user