A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

+4056 -115
+12 -2
cmd/appview/serve.go
··· 420 420 }, 421 421 )).Methods("GET") 422 422 423 + // Manifest detail API endpoint 424 + router.Handle("/api/manifests/{handle}/{repository}/{digest}", middleware.OptionalAuth(sessionStore, database)( 425 + &uihandlers.ManifestDetailHandler{ 426 + DB: readOnlyDB, 427 + Directory: oauthApp.Directory(), 428 + }, 429 + )).Methods("GET") 430 + 423 431 router.Handle("/u/{handle}", middleware.OptionalAuth(sessionStore, database)( 424 432 &uihandlers.UserPageHandler{ 425 433 DB: readOnlyDB, ··· 453 461 }).Methods("POST") 454 462 455 463 authRouter.Handle("/api/images/{repository}/tags/{tag}", &uihandlers.DeleteTagHandler{ 456 - DB: database, 464 + DB: database, 465 + Refresher: refresher, 457 466 }).Methods("DELETE") 458 467 459 468 authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ 460 - DB: database, 469 + DB: database, 470 + Refresher: refresher, 461 471 }).Methods("DELETE") 462 472 463 473 // Device approval page (authenticated)
+71 -3
lexicons/io/atcr/manifest.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["repository", "digest", "mediaType", "schemaVersion", "config", "layers", "holdEndpoint", "createdAt"], 11 + "required": ["repository", "digest", "mediaType", "schemaVersion", "holdEndpoint", "createdAt"], 12 12 "properties": { 13 13 "repository": { 14 14 "type": "string", ··· 29 29 "description": "OCI media type", 30 30 "knownValues": [ 31 31 "application/vnd.oci.image.manifest.v1+json", 32 - "application/vnd.docker.distribution.manifest.v2+json" 32 + "application/vnd.docker.distribution.manifest.v2+json", 33 + "application/vnd.oci.image.index.v1+json", 34 + "application/vnd.docker.distribution.manifest.list.v2+json" 33 35 ] 34 36 }, 35 37 "schemaVersion": { ··· 47 49 "type": "ref", 48 50 "ref": "#blobReference" 49 51 }, 50 - "description": "Filesystem layers" 52 + "description": "Filesystem layers (for image manifests)" 53 + }, 54 + "manifests": { 55 + "type": "array", 56 + "items": { 57 + "type": "ref", 58 + "ref": "#manifestReference" 59 + }, 60 + "description": "Referenced manifests (for manifest lists/indexes)" 51 61 }, 52 62 "annotations": { 53 63 "type": "object", ··· 98 108 "annotations": { 99 109 "type": "object", 100 110 "description": "Optional metadata" 111 + } 112 + } 113 + }, 114 + "manifestReference": { 115 + "type": "object", 116 + "description": "Reference to a manifest in a manifest list/index", 117 + "required": ["mediaType", "size", "digest"], 118 + "properties": { 119 + "mediaType": { 120 + "type": "string", 121 + "description": "Media type of the referenced manifest" 122 + }, 123 + "size": { 124 + "type": "integer", 125 + "description": "Size in bytes" 126 + }, 127 + "digest": { 128 + "type": "string", 129 + "description": "Content digest (e.g., 'sha256:...')" 130 + }, 131 + "platform": { 132 + "type": "ref", 133 + "ref": "#platform", 134 + "description": "Platform information for this manifest" 135 + }, 136 + "annotations": { 137 + "type": "object", 138 + "description": "Optional metadata" 139 + } 140 + } 141 + }, 142 + "platform": { 143 + "type": "object", 144 + "description": "Platform information describing OS and architecture", 145 + "required": ["architecture", "os"], 146 + "properties": { 147 + "architecture": { 148 + "type": "string", 149 + "description": "CPU architecture (e.g., 'amd64', 'arm64', 'arm')" 150 + }, 151 + "os": { 152 + "type": "string", 153 + "description": "Operating system (e.g., 'linux', 'windows', 'darwin')" 154 + }, 155 + "osVersion": { 156 + "type": "string", 157 + "description": "Optional OS version" 158 + }, 159 + "osFeatures": { 160 + "type": "array", 161 + "items": { 162 + "type": "string" 163 + }, 164 + "description": "Optional OS features" 165 + }, 166 + "variant": { 167 + "type": "string", 168 + "description": "Optional CPU variant (e.g., 'v7' for ARM)" 101 169 } 102 170 } 103 171 }
+16
pkg/appview/db/migrations/0004_add_manifest_references.yaml
··· 1 + description: Add manifest_references table for multi-arch manifest support 2 + query: | 3 + CREATE TABLE IF NOT EXISTS manifest_references ( 4 + manifest_id INTEGER NOT NULL, 5 + digest TEXT NOT NULL, 6 + media_type TEXT NOT NULL, 7 + size INTEGER NOT NULL, 8 + platform_architecture TEXT, 9 + platform_os TEXT, 10 + platform_variant TEXT, 11 + platform_os_version TEXT, 12 + reference_index INTEGER NOT NULL, 13 + PRIMARY KEY(manifest_id, reference_index), 14 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 15 + ); 16 + CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest);
+37
pkg/appview/db/models.go
··· 40 40 LayerIndex int 41 41 } 42 42 43 + // ManifestReference represents a reference to a manifest in a manifest list/index 44 + type ManifestReference struct { 45 + ManifestID int64 46 + Digest string 47 + Size int64 48 + MediaType string 49 + PlatformArchitecture string 50 + PlatformOS string 51 + PlatformVariant string 52 + PlatformOSVersion string 53 + ReferenceIndex int 54 + } 55 + 43 56 // Tag represents a tag pointing to a manifest 44 57 type Tag struct { 45 58 ID int64 ··· 120 133 StarCount int 121 134 PullCount int 122 135 } 136 + 137 + // PlatformInfo represents platform information (OS/Architecture) 138 + type PlatformInfo struct { 139 + OS string 140 + Architecture string 141 + Variant string 142 + OSVersion string 143 + } 144 + 145 + // TagWithPlatforms extends Tag with platform information 146 + type TagWithPlatforms struct { 147 + Tag 148 + Platforms []PlatformInfo 149 + IsMultiArch bool 150 + } 151 + 152 + // ManifestWithMetadata extends Manifest with tags and platform information 153 + type ManifestWithMetadata struct { 154 + Manifest 155 + Tags []string 156 + Platforms []PlatformInfo 157 + PlatformCount int 158 + IsManifestList bool 159 + }
+361 -2
pkg/appview/db/queries.go
··· 484 484 return tx.Commit() 485 485 } 486 486 487 - // InsertManifest inserts a new manifest record 487 + // InsertManifest inserts or updates a manifest record 488 + // Uses UPSERT to update labels/annotations if manifest already exists 488 489 func InsertManifest(db *sql.DB, manifest *Manifest) (int64, error) { 489 490 result, err := db.Exec(` 490 - INSERT OR IGNORE INTO manifests 491 + INSERT INTO manifests 491 492 (did, repository, digest, hold_endpoint, schema_version, media_type, 492 493 config_digest, config_size, created_at, 493 494 title, description, source_url, documentation_url, licenses, icon_url) 494 495 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 496 + ON CONFLICT(did, repository, digest) DO UPDATE SET 497 + hold_endpoint = excluded.hold_endpoint, 498 + schema_version = excluded.schema_version, 499 + media_type = excluded.media_type, 500 + config_digest = excluded.config_digest, 501 + config_size = excluded.config_size, 502 + title = excluded.title, 503 + description = excluded.description, 504 + source_url = excluded.source_url, 505 + documentation_url = excluded.documentation_url, 506 + licenses = excluded.licenses, 507 + icon_url = excluded.icon_url 495 508 `, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint, 496 509 manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest, 497 510 manifest.ConfigSize, manifest.CreatedAt, ··· 532 545 DELETE FROM tags WHERE did = ? AND repository = ? AND tag = ? 533 546 `, did, repository, tag) 534 547 return err 548 + } 549 + 550 + // GetTagsWithPlatforms returns all tags for a repository with platform information 551 + // For multi-arch tags, includes all platforms from manifest_references 552 + // For single-arch tags, includes the platform info 553 + func GetTagsWithPlatforms(db *sql.DB, did, repository string) ([]TagWithPlatforms, error) { 554 + rows, err := db.Query(` 555 + SELECT 556 + t.id, 557 + t.did, 558 + t.repository, 559 + t.tag, 560 + t.digest, 561 + t.created_at, 562 + m.media_type, 563 + COALESCE(mr.platform_os, '') as platform_os, 564 + COALESCE(mr.platform_architecture, '') as platform_architecture, 565 + COALESCE(mr.platform_variant, '') as platform_variant, 566 + COALESCE(mr.platform_os_version, '') as platform_os_version 567 + FROM tags t 568 + JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository 569 + LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 570 + WHERE t.did = ? AND t.repository = ? 571 + ORDER BY t.created_at DESC, mr.reference_index 572 + `, did, repository) 573 + 574 + if err != nil { 575 + return nil, err 576 + } 577 + defer rows.Close() 578 + 579 + // Group platforms by tag 580 + tagMap := make(map[string]*TagWithPlatforms) 581 + var tagOrder []string // Preserve order 582 + 583 + for rows.Next() { 584 + var t Tag 585 + var mediaType, platformOS, platformArch, platformVariant, platformOSVersion string 586 + 587 + if err := rows.Scan(&t.ID, &t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt, 588 + &mediaType, &platformOS, &platformArch, &platformVariant, &platformOSVersion); err != nil { 589 + return nil, err 590 + } 591 + 592 + // Get or create TagWithPlatforms 593 + tagKey := t.Tag 594 + if _, exists := tagMap[tagKey]; !exists { 595 + tagMap[tagKey] = &TagWithPlatforms{ 596 + Tag: t, 597 + Platforms: []PlatformInfo{}, 598 + } 599 + tagOrder = append(tagOrder, tagKey) 600 + } 601 + 602 + // Add platform info if present 603 + if platformOS != "" || platformArch != "" { 604 + tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{ 605 + OS: platformOS, 606 + Architecture: platformArch, 607 + Variant: platformVariant, 608 + OSVersion: platformOSVersion, 609 + }) 610 + } 611 + } 612 + 613 + // Convert map to slice, preserving order and setting IsMultiArch 614 + result := make([]TagWithPlatforms, 0, len(tagMap)) 615 + for _, tagKey := range tagOrder { 616 + tag := tagMap[tagKey] 617 + tag.IsMultiArch = len(tag.Platforms) > 1 618 + result = append(result, *tag) 619 + } 620 + 621 + return result, nil 535 622 } 536 623 537 624 // DeleteManifest deletes a manifest and its associated layers ··· 617 704 } 618 705 619 706 return layers, nil 707 + } 708 + 709 + // InsertManifestReference inserts a new manifest reference record (for manifest lists/indexes) 710 + func InsertManifestReference(db *sql.DB, ref *ManifestReference) error { 711 + _, err := db.Exec(` 712 + INSERT INTO manifest_references (manifest_id, digest, size, media_type, 713 + platform_architecture, platform_os, 714 + platform_variant, platform_os_version, 715 + reference_index) 716 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 717 + `, ref.ManifestID, ref.Digest, ref.Size, ref.MediaType, 718 + ref.PlatformArchitecture, ref.PlatformOS, 719 + ref.PlatformVariant, ref.PlatformOSVersion, 720 + ref.ReferenceIndex) 721 + return err 722 + } 723 + 724 + // GetManifestReferencesForManifest fetches all manifest references for a manifest list/index 725 + func GetManifestReferencesForManifest(db *sql.DB, manifestID int64) ([]ManifestReference, error) { 726 + rows, err := db.Query(` 727 + SELECT manifest_id, digest, size, media_type, 728 + platform_architecture, platform_os, platform_variant, platform_os_version, 729 + reference_index 730 + FROM manifest_references 731 + WHERE manifest_id = ? 732 + ORDER BY reference_index 733 + `, manifestID) 734 + 735 + if err != nil { 736 + return nil, err 737 + } 738 + defer rows.Close() 739 + 740 + var refs []ManifestReference 741 + for rows.Next() { 742 + var r ManifestReference 743 + var arch, os, variant, osVersion sql.NullString 744 + if err := rows.Scan(&r.ManifestID, &r.Digest, &r.Size, &r.MediaType, 745 + &arch, &os, &variant, &osVersion, 746 + &r.ReferenceIndex); err != nil { 747 + return nil, err 748 + } 749 + 750 + // Convert nullable strings 751 + if arch.Valid { 752 + r.PlatformArchitecture = arch.String 753 + } 754 + if os.Valid { 755 + r.PlatformOS = os.String 756 + } 757 + if variant.Valid { 758 + r.PlatformVariant = variant.String 759 + } 760 + if osVersion.Valid { 761 + r.PlatformOSVersion = osVersion.String 762 + } 763 + 764 + refs = append(refs, r) 765 + } 766 + 767 + return refs, nil 768 + } 769 + 770 + // GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests 771 + // Filters out platform-specific manifests that are referenced by manifest lists 772 + func GetTopLevelManifests(db *sql.DB, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { 773 + rows, err := db.Query(` 774 + WITH manifest_list_children AS ( 775 + -- Get all digests that are children of manifest lists 776 + SELECT DISTINCT mr.digest 777 + FROM manifest_references mr 778 + JOIN manifests m ON mr.manifest_id = m.id 779 + WHERE m.did = ? AND m.repository = ? 780 + ) 781 + SELECT 782 + m.id, m.did, m.repository, m.digest, m.media_type, 783 + m.schema_version, m.created_at, m.title, m.description, 784 + m.source_url, m.documentation_url, m.licenses, m.icon_url, 785 + m.config_digest, m.config_size, m.hold_endpoint, 786 + GROUP_CONCAT(DISTINCT t.tag) as tags, 787 + COUNT(DISTINCT mr.digest) as platform_count 788 + FROM manifests m 789 + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 790 + LEFT JOIN manifest_references mr ON m.id = mr.manifest_id 791 + WHERE m.did = ? AND m.repository = ? 792 + AND ( 793 + -- Include manifest lists 794 + m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' 795 + OR 796 + -- Include single-arch NOT referenced by any list 797 + m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL) 798 + ) 799 + GROUP BY m.id 800 + ORDER BY m.created_at DESC 801 + LIMIT ? OFFSET ? 802 + `, did, repository, did, repository, limit, offset) 803 + 804 + if err != nil { 805 + return nil, err 806 + } 807 + defer rows.Close() 808 + 809 + var manifests []ManifestWithMetadata 810 + for rows.Next() { 811 + var m ManifestWithMetadata 812 + var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString 813 + var configSize sql.NullInt64 814 + 815 + if err := rows.Scan( 816 + &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 817 + &m.SchemaVersion, &m.CreatedAt, &title, &description, 818 + &sourceURL, &documentationURL, &licenses, &iconURL, 819 + &configDigest, &configSize, &m.HoldEndpoint, 820 + &tags, &m.PlatformCount, 821 + ); err != nil { 822 + return nil, err 823 + } 824 + 825 + // Set nullable fields 826 + if title.Valid { 827 + m.Title = title.String 828 + } 829 + if description.Valid { 830 + m.Description = description.String 831 + } 832 + if sourceURL.Valid { 833 + m.SourceURL = sourceURL.String 834 + } 835 + if documentationURL.Valid { 836 + m.DocumentationURL = documentationURL.String 837 + } 838 + if licenses.Valid { 839 + m.Licenses = licenses.String 840 + } 841 + if iconURL.Valid { 842 + m.IconURL = iconURL.String 843 + } 844 + if configDigest.Valid { 845 + m.ConfigDigest = configDigest.String 846 + } 847 + if configSize.Valid { 848 + m.ConfigSize = configSize.Int64 849 + } 850 + 851 + // Parse tags 852 + if tags.Valid && tags.String != "" { 853 + m.Tags = strings.Split(tags.String, ",") 854 + } 855 + 856 + // Determine if manifest list 857 + m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list") 858 + 859 + manifests = append(manifests, m) 860 + } 861 + 862 + return manifests, nil 863 + } 864 + 865 + // GetManifestDetail returns a manifest with full platform details and tags 866 + func GetManifestDetail(db *sql.DB, did, repository, digest string) (*ManifestWithMetadata, error) { 867 + // First, get the manifest and its tags 868 + var m ManifestWithMetadata 869 + var tags, title, description, sourceURL, documentationURL, licenses, iconURL, configDigest sql.NullString 870 + var configSize sql.NullInt64 871 + 872 + err := db.QueryRow(` 873 + SELECT 874 + m.id, m.did, m.repository, m.digest, m.media_type, 875 + m.schema_version, m.created_at, m.title, m.description, 876 + m.source_url, m.documentation_url, m.licenses, m.icon_url, 877 + m.config_digest, m.config_size, m.hold_endpoint, 878 + GROUP_CONCAT(DISTINCT t.tag) as tags 879 + FROM manifests m 880 + LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository 881 + WHERE m.did = ? AND m.repository = ? AND m.digest = ? 882 + GROUP BY m.id 883 + `, did, repository, digest).Scan( 884 + &m.ID, &m.DID, &m.Repository, &m.Digest, &m.MediaType, 885 + &m.SchemaVersion, &m.CreatedAt, &title, &description, 886 + &sourceURL, &documentationURL, &licenses, &iconURL, 887 + &configDigest, &configSize, &m.HoldEndpoint, 888 + &tags, 889 + ) 890 + 891 + if err != nil { 892 + if err == sql.ErrNoRows { 893 + return nil, fmt.Errorf("manifest not found") 894 + } 895 + return nil, err 896 + } 897 + 898 + // Set nullable fields 899 + if title.Valid { 900 + m.Title = title.String 901 + } 902 + if description.Valid { 903 + m.Description = description.String 904 + } 905 + if sourceURL.Valid { 906 + m.SourceURL = sourceURL.String 907 + } 908 + if documentationURL.Valid { 909 + m.DocumentationURL = documentationURL.String 910 + } 911 + if licenses.Valid { 912 + m.Licenses = licenses.String 913 + } 914 + if iconURL.Valid { 915 + m.IconURL = iconURL.String 916 + } 917 + if configDigest.Valid { 918 + m.ConfigDigest = configDigest.String 919 + } 920 + if configSize.Valid { 921 + m.ConfigSize = configSize.Int64 922 + } 923 + 924 + // Parse tags 925 + if tags.Valid && tags.String != "" { 926 + m.Tags = strings.Split(tags.String, ",") 927 + } 928 + 929 + // Determine if manifest list 930 + m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list") 931 + 932 + // If this is a manifest list, get platform details 933 + if m.IsManifestList { 934 + platforms, err := db.Query(` 935 + SELECT 936 + mr.platform_os, 937 + mr.platform_architecture, 938 + mr.platform_variant, 939 + mr.platform_os_version 940 + FROM manifest_references mr 941 + WHERE mr.manifest_id = ? 942 + ORDER BY mr.reference_index 943 + `, m.ID) 944 + 945 + if err != nil { 946 + return nil, err 947 + } 948 + defer platforms.Close() 949 + 950 + m.Platforms = []PlatformInfo{} 951 + for platforms.Next() { 952 + var p PlatformInfo 953 + var os, arch, variant, osVersion sql.NullString 954 + 955 + if err := platforms.Scan(&os, &arch, &variant, &osVersion); err != nil { 956 + return nil, err 957 + } 958 + 959 + if os.Valid { 960 + p.OS = os.String 961 + } 962 + if arch.Valid { 963 + p.Architecture = arch.String 964 + } 965 + if variant.Valid { 966 + p.Variant = variant.String 967 + } 968 + if osVersion.Valid { 969 + p.OSVersion = osVersion.String 970 + } 971 + 972 + m.Platforms = append(m.Platforms, p) 973 + } 974 + 975 + m.PlatformCount = len(m.Platforms) 976 + } 977 + 978 + return &m, nil 620 979 } 621 980 622 981 // GetFirehoseCursor retrieves the current firehose cursor
+15
pkg/appview/db/schema.go
··· 68 68 ); 69 69 CREATE INDEX IF NOT EXISTS idx_layers_digest ON layers(digest); 70 70 71 + CREATE TABLE IF NOT EXISTS manifest_references ( 72 + manifest_id INTEGER NOT NULL, 73 + digest TEXT NOT NULL, 74 + media_type TEXT NOT NULL, 75 + size INTEGER NOT NULL, 76 + platform_architecture TEXT, 77 + platform_os TEXT, 78 + platform_variant TEXT, 79 + platform_os_version TEXT, 80 + reference_index INTEGER NOT NULL, 81 + PRIMARY KEY(manifest_id, reference_index), 82 + FOREIGN KEY(manifest_id) REFERENCES manifests(id) ON DELETE CASCADE 83 + ); 84 + CREATE INDEX IF NOT EXISTS idx_manifest_references_digest ON manifest_references(digest); 85 + 71 86 CREATE TABLE IF NOT EXISTS tags ( 72 87 id INTEGER PRIMARY KEY AUTOINCREMENT, 73 88 did TEXT NOT NULL,
+37
pkg/appview/handlers/api.go
··· 223 223 json.NewEncoder(w).Encode(stats) 224 224 } 225 225 226 + // ManifestDetailHandler returns detailed manifest information including platforms 227 + type ManifestDetailHandler struct { 228 + DB *sql.DB 229 + Directory identity.Directory 230 + } 231 + 232 + func (h *ManifestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 233 + // Extract parameters 234 + vars := mux.Vars(r) 235 + handle := vars["handle"] 236 + repository := vars["repository"] 237 + digest := vars["digest"] 238 + 239 + // Resolve owner's handle to DID 240 + ownerDID, err := resolveIdentityToDID(r.Context(), h.Directory, handle) 241 + if err != nil { 242 + http.Error(w, "Failed to resolve handle", http.StatusBadRequest) 243 + return 244 + } 245 + 246 + // Get manifest detail from database 247 + manifest, err := db.GetManifestDetail(h.DB, ownerDID, repository, digest) 248 + if err != nil { 249 + if err.Error() == "manifest not found" { 250 + http.Error(w, "Manifest not found", http.StatusNotFound) 251 + return 252 + } 253 + log.Printf("GetManifestDetail error: %v", err) 254 + http.Error(w, "Failed to fetch manifest", http.StatusInternalServerError) 255 + return 256 + } 257 + 258 + // Return manifest as JSON 259 + w.Header().Set("Content-Type", "application/json") 260 + json.NewEncoder(w).Encode(manifest) 261 + } 262 + 226 263 // resolveIdentityToDID is a helper function that resolves a handle or DID to a DID 227 264 func resolveIdentityToDID(ctx context.Context, directory identity.Directory, identityStr string) (string, error) { 228 265 // Parse as AT identifier (handle or DID)
+47 -6
pkg/appview/handlers/images.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 5 6 "net/http" 7 + "strings" 6 8 7 9 "atcr.io/pkg/appview/db" 8 10 "atcr.io/pkg/appview/middleware" 11 + "atcr.io/pkg/atproto" 12 + "atcr.io/pkg/auth/oauth" 9 13 "github.com/gorilla/mux" 10 14 ) 11 15 12 16 // DeleteTagHandler handles deleting a tag 13 17 type DeleteTagHandler struct { 14 - DB *sql.DB 15 - // TODO: Add ATProto client for deleting from PDS 18 + DB *sql.DB 19 + Refresher *oauth.Refresher 16 20 } 17 21 18 22 func (h *DeleteTagHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 26 30 repo := vars["repository"] 27 31 tag := vars["tag"] 28 32 29 - // TODO: Delete from PDS via ATProto client 33 + // Get OAuth session for the authenticated user 34 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 35 + if err != nil { 36 + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 37 + return 38 + } 39 + 40 + // Create ATProto client with OAuth credentials 41 + apiClient := session.APIClient() 42 + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 43 + 44 + // Compute rkey for tag record (repository_tag with slashes replaced) 45 + rkey := fmt.Sprintf("%s_%s", repo, tag) 46 + rkey = strings.ReplaceAll(rkey, "/", "-") 47 + 48 + // Delete from PDS first 49 + if err := pdsClient.DeleteRecord(r.Context(), atproto.TagCollection, rkey); err != nil { 50 + http.Error(w, fmt.Sprintf("Failed to delete tag from PDS: %v", err), http.StatusInternalServerError) 51 + return 52 + } 30 53 31 54 // Delete from cache 32 55 if err := db.DeleteTag(h.DB, user.DID, repo, tag); err != nil { ··· 40 63 41 64 // DeleteManifestHandler handles deleting a manifest 42 65 type DeleteManifestHandler struct { 43 - DB *sql.DB 44 - // TODO: Add ATProto client for deleting from PDS 66 + DB *sql.DB 67 + Refresher *oauth.Refresher 45 68 } 46 69 47 70 func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 67 90 return 68 91 } 69 92 70 - // TODO: Delete from PDS via ATProto client 93 + // Get OAuth session for the authenticated user 94 + session, err := h.Refresher.GetSession(r.Context(), user.DID) 95 + if err != nil { 96 + http.Error(w, fmt.Sprintf("Failed to get OAuth session: %v", err), http.StatusUnauthorized) 97 + return 98 + } 99 + 100 + // Create ATProto client with OAuth credentials 101 + apiClient := session.APIClient() 102 + pdsClient := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 103 + 104 + // Compute rkey for manifest record (digest without "sha256:" prefix) 105 + rkey := strings.TrimPrefix(digest, "sha256:") 106 + 107 + // Delete from PDS first 108 + if err := pdsClient.DeleteRecord(r.Context(), atproto.ManifestCollection, rkey); err != nil { 109 + http.Error(w, fmt.Sprintf("Failed to delete manifest from PDS: %v", err), http.StatusInternalServerError) 110 + return 111 + } 71 112 72 113 // Delete from cache 73 114 if err := db.DeleteManifest(h.DB, user.DID, repo, digest); err != nil {
+26 -8
pkg/appview/handlers/repository.go
··· 40 40 return 41 41 } 42 42 43 - // Fetch repository data 44 - repo, err := db.GetRepository(h.DB, owner.DID, repository) 43 + // Fetch tags with platform information 44 + tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.DB, owner.DID, repository) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + // Fetch top-level manifests (filters out platform-specific manifests) 51 + manifests, err := db.GetTopLevelManifests(h.DB, owner.DID, repository, 50, 0) 45 52 if err != nil { 46 53 http.Error(w, err.Error(), http.StatusInternalServerError) 47 54 return 48 55 } 49 56 50 - if repo == nil || len(repo.Manifests) == 0 { 57 + if len(tagsWithPlatforms) == 0 && len(manifests) == 0 { 51 58 http.Error(w, "Repository not found", http.StatusNotFound) 52 59 return 53 60 } 54 61 62 + // Create repository summary 63 + repo := &db.Repository{ 64 + Name: repository, 65 + TagCount: len(tagsWithPlatforms), 66 + ManifestCount: len(manifests), 67 + } 68 + 55 69 // Fetch star count 56 70 stats, err := db.GetRepositoryStats(h.DB, owner.DID, repository) 57 71 if err != nil { ··· 86 100 87 101 data := struct { 88 102 PageData 89 - Owner *db.User // Repository owner 90 - Repository *db.Repository 91 - StarCount int 92 - IsStarred bool 93 - IsOwner bool // Whether current user owns this repository 103 + Owner *db.User // Repository owner 104 + Repository *db.Repository // Repository summary 105 + Tags []db.TagWithPlatforms // Tags with platform info 106 + Manifests []db.ManifestWithMetadata // Top-level manifests only 107 + StarCount int 108 + IsStarred bool 109 + IsOwner bool // Whether current user owns this repository 94 110 }{ 95 111 PageData: NewPageData(r, h.RegistryURL), 96 112 Owner: owner, 97 113 Repository: repo, 114 + Tags: tagsWithPlatforms, 115 + Manifests: manifests, 98 116 StarCount: stats.StarCount, 99 117 IsStarred: isStarred, 100 118 IsOwner: isOwner,
+58 -16
pkg/appview/jetstream/backfill.go
··· 308 308 iconURL = manifestRecord.Annotations["io.atcr.icon"] 309 309 } 310 310 311 - // Insert manifest 312 - manifestID, err := db.InsertManifest(b.db, &db.Manifest{ 311 + // Detect manifest type 312 + isManifestList := len(manifestRecord.Manifests) > 0 313 + 314 + // Prepare manifest for insertion 315 + manifest := &db.Manifest{ 313 316 DID: did, 314 317 Repository: manifestRecord.Repository, 315 318 Digest: manifestRecord.Digest, 316 319 MediaType: manifestRecord.MediaType, 317 320 SchemaVersion: manifestRecord.SchemaVersion, 318 - ConfigDigest: manifestRecord.Config.Digest, 319 - ConfigSize: manifestRecord.Config.Size, 320 321 HoldEndpoint: manifestRecord.HoldEndpoint, 321 322 CreatedAt: manifestRecord.CreatedAt, 322 323 Title: title, ··· 325 326 DocumentationURL: documentationURL, 326 327 Licenses: licenses, 327 328 IconURL: iconURL, 328 - }) 329 + } 330 + 331 + // Set config fields only for image manifests (not manifest lists) 332 + if !isManifestList && manifestRecord.Config != nil { 333 + manifest.ConfigDigest = manifestRecord.Config.Digest 334 + manifest.ConfigSize = manifestRecord.Config.Size 335 + } 336 + 337 + // Insert manifest 338 + manifestID, err := db.InsertManifest(b.db, manifest) 329 339 if err != nil { 330 340 // Skip if already exists 331 341 if strings.Contains(err.Error(), "UNIQUE constraint failed") { ··· 334 344 return fmt.Errorf("failed to insert manifest: %w", err) 335 345 } 336 346 337 - // Insert layers 338 - for i, layer := range manifestRecord.Layers { 339 - if err := db.InsertLayer(b.db, &db.Layer{ 340 - ManifestID: manifestID, 341 - Digest: layer.Digest, 342 - MediaType: layer.MediaType, 343 - Size: layer.Size, 344 - LayerIndex: i, 345 - }); err != nil { 346 - // Continue on error - layer might already exist 347 - continue 347 + if isManifestList { 348 + // Insert manifest references (for manifest lists/indexes) 349 + for i, ref := range manifestRecord.Manifests { 350 + platformArch := "" 351 + platformOS := "" 352 + platformVariant := "" 353 + platformOSVersion := "" 354 + 355 + if ref.Platform != nil { 356 + platformArch = ref.Platform.Architecture 357 + platformOS = ref.Platform.OS 358 + platformVariant = ref.Platform.Variant 359 + platformOSVersion = ref.Platform.OSVersion 360 + } 361 + 362 + if err := db.InsertManifestReference(b.db, &db.ManifestReference{ 363 + ManifestID: manifestID, 364 + Digest: ref.Digest, 365 + MediaType: ref.MediaType, 366 + Size: ref.Size, 367 + PlatformArchitecture: platformArch, 368 + PlatformOS: platformOS, 369 + PlatformVariant: platformVariant, 370 + PlatformOSVersion: platformOSVersion, 371 + ReferenceIndex: i, 372 + }); err != nil { 373 + // Continue on error - reference might already exist 374 + continue 375 + } 376 + } 377 + } else { 378 + // Insert layers (for image manifests) 379 + for i, layer := range manifestRecord.Layers { 380 + if err := db.InsertLayer(b.db, &db.Layer{ 381 + ManifestID: manifestID, 382 + Digest: layer.Digest, 383 + MediaType: layer.MediaType, 384 + Size: layer.Size, 385 + LayerIndex: i, 386 + }); err != nil { 387 + // Continue on error - layer might already exist 388 + continue 389 + } 348 390 } 349 391 } 350 392
+58 -16
pkg/appview/jetstream/worker.go
··· 452 452 iconURL = manifestRecord.Annotations["io.atcr.icon"] 453 453 } 454 454 455 - // Insert manifest 456 - manifestID, err := db.InsertManifest(w.db, &db.Manifest{ 455 + // Detect manifest type 456 + isManifestList := len(manifestRecord.Manifests) > 0 457 + 458 + // Prepare manifest for insertion 459 + manifest := &db.Manifest{ 457 460 DID: commit.DID, 458 461 Repository: manifestRecord.Repository, 459 462 Digest: manifestRecord.Digest, 460 463 MediaType: manifestRecord.MediaType, 461 464 SchemaVersion: manifestRecord.SchemaVersion, 462 - ConfigDigest: manifestRecord.Config.Digest, 463 - ConfigSize: manifestRecord.Config.Size, 464 465 HoldEndpoint: manifestRecord.HoldEndpoint, 465 466 CreatedAt: manifestRecord.CreatedAt, 466 467 Title: title, ··· 469 470 DocumentationURL: documentationURL, 470 471 Licenses: licenses, 471 472 IconURL: iconURL, 472 - }) 473 + } 474 + 475 + // Set config fields only for image manifests (not manifest lists) 476 + if !isManifestList && manifestRecord.Config != nil { 477 + manifest.ConfigDigest = manifestRecord.Config.Digest 478 + manifest.ConfigSize = manifestRecord.Config.Size 479 + } 480 + 481 + // Insert manifest 482 + manifestID, err := db.InsertManifest(w.db, manifest) 473 483 if err != nil { 474 484 return fmt.Errorf("failed to insert manifest: %w", err) 475 485 } 476 486 477 - // Insert layers 478 - for i, layer := range manifestRecord.Layers { 479 - if err := db.InsertLayer(w.db, &db.Layer{ 480 - ManifestID: manifestID, 481 - Digest: layer.Digest, 482 - MediaType: layer.MediaType, 483 - Size: layer.Size, 484 - LayerIndex: i, 485 - }); err != nil { 486 - // Continue on error - layer might already exist 487 - continue 487 + if isManifestList { 488 + // Insert manifest references (for manifest lists/indexes) 489 + for i, ref := range manifestRecord.Manifests { 490 + platformArch := "" 491 + platformOS := "" 492 + platformVariant := "" 493 + platformOSVersion := "" 494 + 495 + if ref.Platform != nil { 496 + platformArch = ref.Platform.Architecture 497 + platformOS = ref.Platform.OS 498 + platformVariant = ref.Platform.Variant 499 + platformOSVersion = ref.Platform.OSVersion 500 + } 501 + 502 + if err := db.InsertManifestReference(w.db, &db.ManifestReference{ 503 + ManifestID: manifestID, 504 + Digest: ref.Digest, 505 + MediaType: ref.MediaType, 506 + Size: ref.Size, 507 + PlatformArchitecture: platformArch, 508 + PlatformOS: platformOS, 509 + PlatformVariant: platformVariant, 510 + PlatformOSVersion: platformOSVersion, 511 + ReferenceIndex: i, 512 + }); err != nil { 513 + // Continue on error - reference might already exist 514 + continue 515 + } 516 + } 517 + } else { 518 + // Insert layers (for image manifests) 519 + for i, layer := range manifestRecord.Layers { 520 + if err := db.InsertLayer(w.db, &db.Layer{ 521 + ManifestID: manifestID, 522 + Digest: layer.Digest, 523 + MediaType: layer.MediaType, 524 + Size: layer.Size, 525 + LayerIndex: i, 526 + }); err != nil { 527 + // Continue on error - layer might already exist 528 + continue 529 + } 488 530 } 489 531 } 490 532
+55
pkg/appview/static/css/style.css
··· 1049 1049 color: var(--secondary); 1050 1050 } 1051 1051 1052 + /* Multi-architecture badges */ 1053 + .badge-multi { 1054 + display: inline-flex; 1055 + align-items: center; 1056 + padding: 0.25rem 0.6rem; 1057 + font-size: 0.75rem; 1058 + font-weight: 600; 1059 + border-radius: 12px; 1060 + background: var(--primary); 1061 + color: var(--bg); 1062 + white-space: nowrap; 1063 + margin-left: 0.5rem; 1064 + } 1065 + 1066 + .platform-badge { 1067 + display: inline-flex; 1068 + align-items: center; 1069 + padding: 0.2rem 0.5rem; 1070 + font-size: 0.75rem; 1071 + font-weight: 500; 1072 + border-radius: 4px; 1073 + background: var(--code-bg); 1074 + color: var(--fg); 1075 + border: 1px solid var(--border); 1076 + white-space: nowrap; 1077 + font-family: 'Monaco', 'Courier New', monospace; 1078 + } 1079 + 1080 + .platforms-inline { 1081 + display: flex; 1082 + flex-wrap: wrap; 1083 + gap: 0.5rem; 1084 + align-items: center; 1085 + } 1086 + 1087 + .manifest-type { 1088 + display: inline-flex; 1089 + align-items: center; 1090 + gap: 0.35rem; 1091 + font-size: 0.9rem; 1092 + font-weight: 500; 1093 + color: var(--secondary); 1094 + } 1095 + 1096 + .platform-count { 1097 + color: var(--border-dark); 1098 + font-size: 0.85rem; 1099 + font-style: italic; 1100 + } 1101 + 1102 + .text-muted { 1103 + color: var(--border-dark); 1104 + font-style: italic; 1105 + } 1106 + 1052 1107 /* Featured Repositories Section */ 1053 1108 .featured-section { 1054 1109 margin-bottom: 3rem;
+5 -4
pkg/appview/storage/proxy_blob_store.go
··· 12 12 "sync" 13 13 "time" 14 14 15 + "atcr.io/pkg/atproto" 15 16 "github.com/distribution/distribution/v3" 16 17 "github.com/opencontainers/go-digest" 17 18 ) ··· 508 509 return "", err 509 510 } 510 511 511 - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.initiateUpload", p.holdURL) 512 + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldInitiateUpload) 512 513 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 513 514 if err != nil { 514 515 return "", err ··· 556 557 return nil, err 557 558 } 558 559 559 - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.getPartUploadUrl", p.holdURL) 560 + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldGetPartUploadUrl) 560 561 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 561 562 if err != nil { 562 563 return nil, err ··· 606 607 return err 607 608 } 608 609 609 - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.completeUpload", p.holdURL) 610 + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldCompleteUpload) 610 611 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 611 612 if err != nil { 612 613 return err ··· 639 640 return err 640 641 } 641 642 642 - url := fmt.Sprintf("%s/xrpc/io.atcr.hold.abortUpload", p.holdURL) 643 + url := fmt.Sprintf("%s%s", p.holdURL, atproto.HoldAbortUpload) 643 644 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) 644 645 if err != nil { 645 646 return err
+326
pkg/appview/storage/proxy_blob_store_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "net/http" 6 7 "net/http/httptest" 7 8 "strings" 8 9 "testing" 9 10 "time" 11 + 12 + "atcr.io/pkg/atproto" 13 + "github.com/opencontainers/go-digest" 10 14 ) 11 15 12 16 // TestGetServiceToken_CachingLogic tests the token caching mechanism ··· 343 347 } 344 348 } 345 349 } 350 + 351 + // TestCompleteMultipartUpload_JSONFormat verifies the JSON request format sent to hold service 352 + // This test would have caught the "partNumber" vs "part_number" bug 353 + func TestCompleteMultipartUpload_JSONFormat(t *testing.T) { 354 + var capturedBody map[string]any 355 + 356 + // Mock hold service that captures the request body 357 + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 358 + if !strings.Contains(r.URL.Path, atproto.HoldCompleteUpload) { 359 + t.Errorf("Wrong endpoint called: %s", r.URL.Path) 360 + } 361 + 362 + // Capture request body 363 + var body map[string]any 364 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 365 + t.Errorf("Failed to decode request body: %v", err) 366 + } 367 + capturedBody = body 368 + 369 + w.Header().Set("Content-Type", "application/json") 370 + w.WriteHeader(http.StatusOK) 371 + w.Write([]byte(`{}`)) 372 + })) 373 + defer holdServer.Close() 374 + 375 + // Create store with mocked hold URL 376 + ctx := &RegistryContext{ 377 + DID: "did:plc:test", 378 + HoldDID: "did:web:hold.example.com", 379 + PDSEndpoint: "https://pds.example.com", 380 + Repository: "test-repo", 381 + } 382 + store := NewProxyBlobStore(ctx) 383 + store.holdURL = holdServer.URL 384 + 385 + // Setup token cache to avoid auth errors 386 + globalServiceTokensMu.Lock() 387 + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ 388 + token: "test-token", 389 + expiresAt: time.Now().Add(50 * time.Second), 390 + } 391 + globalServiceTokensMu.Unlock() 392 + 393 + // Call completeMultipartUpload 394 + parts := []CompletedPart{ 395 + {PartNumber: 1, ETag: "etag-1"}, 396 + {PartNumber: 2, ETag: "etag-2"}, 397 + } 398 + err := store.completeMultipartUpload(context.Background(), "sha256:abc123", "upload-id-xyz", parts) 399 + if err != nil { 400 + t.Fatalf("completeMultipartUpload failed: %v", err) 401 + } 402 + 403 + // Verify JSON format 404 + if capturedBody == nil { 405 + t.Fatal("No request body was captured") 406 + } 407 + 408 + // Check top-level fields 409 + if uploadID, ok := capturedBody["uploadId"].(string); !ok || uploadID != "upload-id-xyz" { 410 + t.Errorf("Expected uploadId='upload-id-xyz', got %v", capturedBody["uploadId"]) 411 + } 412 + if digest, ok := capturedBody["digest"].(string); !ok || digest != "sha256:abc123" { 413 + t.Errorf("Expected digest='sha256:abc123', got %v", capturedBody["digest"]) 414 + } 415 + 416 + // Check parts array 417 + partsArray, ok := capturedBody["parts"].([]any) 418 + if !ok { 419 + t.Fatalf("Expected parts to be array, got %T", capturedBody["parts"]) 420 + } 421 + if len(partsArray) != 2 { 422 + t.Fatalf("Expected 2 parts, got %d", len(partsArray)) 423 + } 424 + 425 + // Verify first part has "part_number" (not "partNumber") 426 + part0, ok := partsArray[0].(map[string]any) 427 + if !ok { 428 + t.Fatalf("Expected part to be object, got %T", partsArray[0]) 429 + } 430 + 431 + // THIS IS THE KEY CHECK - would have caught the bug 432 + if _, hasPartNumber := part0["partNumber"]; hasPartNumber { 433 + t.Error("Found 'partNumber' (camelCase) - should be 'part_number' (snake_case)") 434 + } 435 + if partNum, ok := part0["part_number"].(float64); !ok || int(partNum) != 1 { 436 + t.Errorf("Expected part_number=1, got %v", part0["part_number"]) 437 + } 438 + if etag, ok := part0["etag"].(string); !ok || etag != "etag-1" { 439 + t.Errorf("Expected etag='etag-1', got %v", part0["etag"]) 440 + } 441 + } 442 + 443 + // TestGet_UsesPresignedURLDirectly verifies that Get() doesn't add auth headers to presigned URLs 444 + // This test would have caught the presigned URL authentication bug 445 + func TestGet_UsesPresignedURLDirectly(t *testing.T) { 446 + blobData := []byte("test blob content") 447 + var s3ReceivedAuthHeader string 448 + 449 + // Mock S3 server that rejects requests with Authorization header 450 + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 451 + s3ReceivedAuthHeader = r.Header.Get("Authorization") 452 + 453 + // Presigned URLs should NOT have Authorization header 454 + if s3ReceivedAuthHeader != "" { 455 + t.Errorf("S3 received Authorization header: %s (should be empty for presigned URLs)", s3ReceivedAuthHeader) 456 + w.WriteHeader(http.StatusForbidden) 457 + w.Write([]byte(`<?xml version="1.0"?><Error><Code>SignatureDoesNotMatch</Code></Error>`)) 458 + return 459 + } 460 + 461 + // Return blob data 462 + w.WriteHeader(http.StatusOK) 463 + w.Write(blobData) 464 + })) 465 + defer s3Server.Close() 466 + 467 + // Mock hold service that returns presigned S3 URL 468 + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 469 + // Return presigned URL pointing to S3 server 470 + w.Header().Set("Content-Type", "application/json") 471 + w.WriteHeader(http.StatusOK) 472 + resp := map[string]string{ 473 + "url": s3Server.URL + "/blob?X-Amz-Signature=fake-signature", 474 + } 475 + json.NewEncoder(w).Encode(resp) 476 + })) 477 + defer holdServer.Close() 478 + 479 + // Create store 480 + ctx := &RegistryContext{ 481 + DID: "did:plc:test", 482 + HoldDID: "did:web:hold.example.com", 483 + PDSEndpoint: "https://pds.example.com", 484 + Repository: "test-repo", 485 + } 486 + store := NewProxyBlobStore(ctx) 487 + store.holdURL = holdServer.URL 488 + 489 + // Setup token cache 490 + globalServiceTokensMu.Lock() 491 + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ 492 + token: "test-token", 493 + expiresAt: time.Now().Add(50 * time.Second), 494 + } 495 + globalServiceTokensMu.Unlock() 496 + 497 + // Call Get() 498 + dgst := digest.FromBytes(blobData) 499 + retrieved, err := store.Get(context.Background(), dgst) 500 + if err != nil { 501 + t.Fatalf("Get() failed: %v", err) 502 + } 503 + 504 + // Verify correct data was retrieved 505 + if string(retrieved) != string(blobData) { 506 + t.Errorf("Expected data=%s, got %s", string(blobData), string(retrieved)) 507 + } 508 + 509 + // Verify S3 received NO Authorization header 510 + if s3ReceivedAuthHeader != "" { 511 + t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader) 512 + } 513 + } 514 + 515 + // TestOpen_UsesPresignedURLDirectly verifies that Open() doesn't add auth headers to presigned URLs 516 + // This test would have caught the presigned URL authentication bug 517 + func TestOpen_UsesPresignedURLDirectly(t *testing.T) { 518 + blobData := []byte("test blob stream content") 519 + var s3ReceivedAuthHeader string 520 + 521 + // Mock S3 server 522 + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 523 + s3ReceivedAuthHeader = r.Header.Get("Authorization") 524 + 525 + // Presigned URLs should NOT have Authorization header 526 + if s3ReceivedAuthHeader != "" { 527 + t.Errorf("S3 received Authorization header: %s (should be empty)", s3ReceivedAuthHeader) 528 + w.WriteHeader(http.StatusForbidden) 529 + return 530 + } 531 + 532 + w.WriteHeader(http.StatusOK) 533 + w.Write(blobData) 534 + })) 535 + defer s3Server.Close() 536 + 537 + // Mock hold service 538 + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 539 + w.Header().Set("Content-Type", "application/json") 540 + w.WriteHeader(http.StatusOK) 541 + json.NewEncoder(w).Encode(map[string]string{ 542 + "url": s3Server.URL + "/blob?X-Amz-Signature=fake", 543 + }) 544 + })) 545 + defer holdServer.Close() 546 + 547 + // Create store 548 + ctx := &RegistryContext{ 549 + DID: "did:plc:test", 550 + HoldDID: "did:web:hold.example.com", 551 + PDSEndpoint: "https://pds.example.com", 552 + Repository: "test-repo", 553 + } 554 + store := NewProxyBlobStore(ctx) 555 + store.holdURL = holdServer.URL 556 + 557 + // Setup token cache 558 + globalServiceTokensMu.Lock() 559 + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ 560 + token: "test-token", 561 + expiresAt: time.Now().Add(50 * time.Second), 562 + } 563 + globalServiceTokensMu.Unlock() 564 + 565 + // Call Open() 566 + dgst := digest.FromBytes(blobData) 567 + reader, err := store.Open(context.Background(), dgst) 568 + if err != nil { 569 + t.Fatalf("Open() failed: %v", err) 570 + } 571 + defer reader.Close() 572 + 573 + // Verify S3 received NO Authorization header 574 + if s3ReceivedAuthHeader != "" { 575 + t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader) 576 + } 577 + } 578 + 579 + // TestMultipartEndpoints_CorrectURLs verifies all multipart XRPC endpoints use correct URLs 580 + // This would have caught the old com.atproto.repo.uploadBlob vs new io.atcr.hold.* endpoints 581 + func TestMultipartEndpoints_CorrectURLs(t *testing.T) { 582 + tests := []struct { 583 + name string 584 + testFunc func(*ProxyBlobStore) error 585 + expectedPath string 586 + }{ 587 + { 588 + name: "startMultipartUpload", 589 + testFunc: func(store *ProxyBlobStore) error { 590 + _, err := store.startMultipartUpload(context.Background(), "sha256:test") 591 + return err 592 + }, 593 + expectedPath: atproto.HoldInitiateUpload, 594 + }, 595 + { 596 + name: "getPartUploadInfo", 597 + testFunc: func(store *ProxyBlobStore) error { 598 + _, err := store.getPartUploadInfo(context.Background(), "sha256:test", "upload-123", 1) 599 + return err 600 + }, 601 + expectedPath: atproto.HoldGetPartUploadUrl, 602 + }, 603 + { 604 + name: "completeMultipartUpload", 605 + testFunc: func(store *ProxyBlobStore) error { 606 + parts := []CompletedPart{{PartNumber: 1, ETag: "etag1"}} 607 + return store.completeMultipartUpload(context.Background(), "sha256:test", "upload-123", parts) 608 + }, 609 + expectedPath: atproto.HoldCompleteUpload, 610 + }, 611 + { 612 + name: "abortMultipartUpload", 613 + testFunc: func(store *ProxyBlobStore) error { 614 + return store.abortMultipartUpload(context.Background(), "sha256:test", "upload-123") 615 + }, 616 + expectedPath: atproto.HoldAbortUpload, 617 + }, 618 + } 619 + 620 + for _, tt := range tests { 621 + t.Run(tt.name, func(t *testing.T) { 622 + var capturedPath string 623 + 624 + // Mock hold service that captures request path 625 + holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 626 + capturedPath = r.URL.Path 627 + 628 + // Return success response 629 + w.Header().Set("Content-Type", "application/json") 630 + w.WriteHeader(http.StatusOK) 631 + resp := map[string]string{ 632 + "uploadId": "test-upload-id", 633 + "url": "https://s3.example.com/presigned", 634 + } 635 + json.NewEncoder(w).Encode(resp) 636 + })) 637 + defer holdServer.Close() 638 + 639 + // Create store 640 + ctx := &RegistryContext{ 641 + DID: "did:plc:test", 642 + HoldDID: "did:web:hold.example.com", 643 + PDSEndpoint: "https://pds.example.com", 644 + Repository: "test-repo", 645 + } 646 + store := NewProxyBlobStore(ctx) 647 + store.holdURL = holdServer.URL 648 + 649 + // Setup token cache 650 + globalServiceTokensMu.Lock() 651 + globalServiceTokens["did:plc:test:did:web:hold.example.com"] = &serviceTokenEntry{ 652 + token: "test-token", 653 + expiresAt: time.Now().Add(50 * time.Second), 654 + } 655 + globalServiceTokensMu.Unlock() 656 + 657 + // Call the function 658 + _ = tt.testFunc(store) // Ignore error, we just care about the URL 659 + 660 + // Verify correct endpoint was called 661 + if capturedPath != tt.expectedPath { 662 + t.Errorf("Expected endpoint %s, got %s", tt.expectedPath, capturedPath) 663 + } 664 + 665 + // Verify it's NOT the old endpoint 666 + if strings.Contains(capturedPath, "com.atproto.repo.uploadBlob") { 667 + t.Error("Still using old com.atproto.repo.uploadBlob endpoint!") 668 + } 669 + }) 670 + } 671 + }
+68 -25
pkg/appview/templates/pages/repository.html
··· 64 64 <!-- Pull Command --> 65 65 <div class="pull-command-section"> 66 66 <h3>Pull this image</h3> 67 - {{ if .Repository.Tags }} 68 - {{ $firstTag := index .Repository.Tags 0 }} 67 + {{ if .Tags }} 68 + {{ $firstTag := index .Tags 0 }} 69 69 <div class="push-command"> 70 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}</code> 71 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}')"> 70 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}</code> 71 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}')"> 72 72 Copy 73 73 </button> 74 74 </div> ··· 86 86 <!-- Tags Section --> 87 87 <div class="repo-section"> 88 88 <h2>Tags</h2> 89 - {{ if .Repository.Tags }} 89 + {{ if .Tags }} 90 90 <div class="tags-list"> 91 - {{ range .Repository.Tags }} 92 - <div class="tag-item" id="tag-{{ .Tag }}"> 91 + {{ range .Tags }} 92 + <div class="tag-item" id="tag-{{ .Tag.Tag }}"> 93 93 <div class="tag-item-header"> 94 - <span class="tag-name-large">{{ .Tag }}</span> 94 + <div> 95 + <span class="tag-name-large">{{ .Tag.Tag }}</span> 96 + {{ if .IsMultiArch }} 97 + <span class="badge-multi">Multi-arch</span> 98 + {{ end }} 99 + </div> 95 100 <div style="display: flex; gap: 1rem; align-items: center;"> 96 - <time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 97 - {{ timeAgo .CreatedAt }} 101 + <time class="tag-timestamp" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 102 + {{ timeAgo .Tag.CreatedAt }} 98 103 </time> 99 104 {{ if $.IsOwner }} 100 105 <button class="delete-btn" 101 - hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag }}" 102 - hx-confirm="Delete tag {{ .Tag }}?" 103 - hx-target="#tag-{{ .Tag }}" 106 + hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag.Tag }}" 107 + hx-confirm="Delete tag {{ .Tag.Tag }}?" 108 + hx-target="#tag-{{ .Tag.Tag }}" 104 109 hx-swap="outerHTML"> 105 110 🗑️ 106 111 </button> ··· 108 113 </div> 109 114 </div> 110 115 <div class="tag-item-details"> 111 - <code class="digest">{{ .Digest }}</code> 116 + <div style="display: flex; justify-content: space-between; align-items: center;"> 117 + <code class="digest">{{ .Tag.Digest }}</code> 118 + {{ if .Platforms }} 119 + <div class="platforms-inline"> 120 + {{ range .Platforms }} 121 + <span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 122 + {{ end }} 123 + </div> 124 + {{ end }} 125 + </div> 112 126 </div> 113 127 <div class="push-command"> 114 - <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}</code> 115 - <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}')"> 128 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}</code> 129 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}')"> 116 130 Copy 117 131 </button> 118 132 </div> ··· 127 141 <!-- Manifests Section --> 128 142 <div class="repo-section"> 129 143 <h2>Manifests</h2> 130 - {{ if .Repository.Manifests }} 144 + {{ if .Manifests }} 131 145 <div class="manifests-list"> 132 - {{ range .Repository.Manifests }} 133 - <div class="manifest-item"> 146 + {{ range .Manifests }} 147 + <div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}"> 134 148 <div class="manifest-item-header"> 135 - <code class="manifest-digest">{{ .Digest }}</code> 136 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 137 - {{ timeAgo .CreatedAt }} 138 - </time> 149 + <div> 150 + {{ if .IsManifestList }} 151 + <span class="manifest-type">📦 Multi-arch</span> 152 + {{ else }} 153 + <span class="manifest-type">📄 Image</span> 154 + {{ end }} 155 + <code class="manifest-digest">{{ .Manifest.Digest }}</code> 156 + </div> 157 + <div style="display: flex; gap: 1rem; align-items: center;"> 158 + <time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 159 + {{ timeAgo .Manifest.CreatedAt }} 160 + </time> 161 + {{ if $.IsOwner }} 162 + <button class="delete-btn" 163 + hx-delete="/api/images/{{ $.Repository.Name }}/manifests/{{ .Manifest.Digest }}" 164 + hx-confirm="Delete manifest {{ .Manifest.Digest }}? This cannot be undone." 165 + hx-target="#manifest-{{ sanitizeID .Manifest.Digest }}" 166 + hx-swap="outerHTML"> 167 + 🗑️ 168 + </button> 169 + {{ end }} 170 + </div> 139 171 </div> 140 172 <div class="manifest-item-details"> 141 - <span class="manifest-detail-label">Storage:</span> 142 - <span>{{ .HoldEndpoint }}</span> 173 + <div style="display: flex; justify-content: space-between; align-items: center;"> 174 + <div> 175 + {{ if .Tags }} 176 + <span class="manifest-detail-label">Tags:</span> 177 + {{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }} 178 + {{ else }} 179 + <span class="text-muted">(untagged)</span> 180 + {{ end }} 181 + </div> 182 + {{ if .IsManifestList }} 183 + <span class="platform-count">{{ .PlatformCount }} platforms</span> 184 + {{ end }} 185 + </div> 143 186 </div> 144 187 </div> 145 188 {{ end }}
+7
pkg/appview/ui.go
··· 6 6 "html/template" 7 7 "io/fs" 8 8 "net/http" 9 + "strings" 9 10 "time" 10 11 ) 11 12 ··· 76 77 return s[len(prefix):] 77 78 } 78 79 return s 80 + }, 81 + 82 + "sanitizeID": func(s string) string { 83 + // Replace colons with dashes to make valid CSS selectors 84 + // e.g., "sha256:abc123" becomes "sha256-abc123" 85 + return strings.ReplaceAll(s, ":", "-") 79 86 }, 80 87 } 81 88
+76
pkg/appview/ui_test.go
··· 437 437 } 438 438 } 439 439 440 + func TestSanitizeID(t *testing.T) { 441 + tests := []struct { 442 + name string 443 + input string 444 + expected string 445 + }{ 446 + { 447 + name: "digest with colon", 448 + input: "sha256:abc123", 449 + expected: "sha256-abc123", 450 + }, 451 + { 452 + name: "full digest", 453 + input: "sha256:f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", 454 + expected: "sha256-f1c8f6a4b7e9d2c0a3f5b8e1d4c7a0b3e6f9c2d5a8b1e4f7c0d3a6b9e2f5c8a1", 455 + }, 456 + { 457 + name: "multiple colons", 458 + input: "sha256:abc:def:ghi", 459 + expected: "sha256-abc-def-ghi", 460 + }, 461 + { 462 + name: "no colons", 463 + input: "abcdef123456", 464 + expected: "abcdef123456", 465 + }, 466 + { 467 + name: "empty string", 468 + input: "", 469 + expected: "", 470 + }, 471 + { 472 + name: "only colon", 473 + input: ":", 474 + expected: "-", 475 + }, 476 + { 477 + name: "leading colon", 478 + input: ":abc", 479 + expected: "-abc", 480 + }, 481 + { 482 + name: "trailing colon", 483 + input: "abc:", 484 + expected: "abc-", 485 + }, 486 + } 487 + 488 + for _, tt := range tests { 489 + t.Run(tt.name, func(t *testing.T) { 490 + // Get fresh template for each test case 491 + tmpl, err := Templates() 492 + if err != nil { 493 + t.Fatalf("Templates() error = %v", err) 494 + } 495 + 496 + templateStr := `{{ sanitizeID . }}` 497 + buf := new(bytes.Buffer) 498 + temp, err := tmpl.New("test").Parse(templateStr) 499 + if err != nil { 500 + t.Fatalf("Failed to parse template: %v", err) 501 + } 502 + 503 + err = temp.Execute(buf, tt.input) 504 + if err != nil { 505 + t.Fatalf("Failed to execute template: %v", err) 506 + } 507 + 508 + got := buf.String() 509 + if got != tt.expected { 510 + t.Errorf("sanitizeID(%q) = %q, want %q", tt.input, got, tt.expected) 511 + } 512 + }) 513 + } 514 + } 515 + 440 516 func TestTemplates(t *testing.T) { 441 517 tmpl, err := Templates() 442 518 if err != nil {
+693
pkg/atproto/client_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + "time" 11 + ) 12 + 13 + // TestNewClient verifies client initialization with Basic Auth 14 + func TestNewClient(t *testing.T) { 15 + client := NewClient("https://pds.example.com", "did:plc:test123", "token123") 16 + 17 + if client.pdsEndpoint != "https://pds.example.com" { 18 + t.Errorf("pdsEndpoint = %v, want https://pds.example.com", client.pdsEndpoint) 19 + } 20 + if client.did != "did:plc:test123" { 21 + t.Errorf("did = %v, want did:plc:test123", client.did) 22 + } 23 + if client.accessToken != "token123" { 24 + t.Errorf("accessToken = %v, want token123", client.accessToken) 25 + } 26 + if client.useIndigoClient { 27 + t.Error("useIndigoClient should be false for Basic Auth client") 28 + } 29 + } 30 + 31 + // TestPutRecord tests storing a record in ATProto 32 + func TestPutRecord(t *testing.T) { 33 + tests := []struct { 34 + name string 35 + collection string 36 + rkey string 37 + record interface{} 38 + serverResponse string 39 + serverStatus int 40 + wantErr bool 41 + checkFunc func(*testing.T, *Record) 42 + }{ 43 + { 44 + name: "successful put", 45 + collection: ManifestCollection, 46 + rkey: "abc123", 47 + record: map[string]string{ 48 + "$type": ManifestCollection, 49 + "test": "value", 50 + }, 51 + serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest"}`, 52 + serverStatus: http.StatusOK, 53 + wantErr: false, 54 + checkFunc: func(t *testing.T, r *Record) { 55 + if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { 56 + t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) 57 + } 58 + if r.CID != "bafytest" { 59 + t.Errorf("CID = %v, want bafytest", r.CID) 60 + } 61 + }, 62 + }, 63 + { 64 + name: "server error", 65 + collection: ManifestCollection, 66 + rkey: "abc123", 67 + record: map[string]string{"test": "value"}, 68 + serverResponse: `{"error":"InvalidRequest"}`, 69 + serverStatus: http.StatusBadRequest, 70 + wantErr: true, 71 + }, 72 + } 73 + 74 + for _, tt := range tests { 75 + t.Run(tt.name, func(t *testing.T) { 76 + // Create test server 77 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 78 + // Verify request method 79 + if r.Method != "POST" { 80 + t.Errorf("Method = %v, want POST", r.Method) 81 + } 82 + 83 + // Verify path 84 + expectedPath := "/xrpc/com.atproto.repo.putRecord" 85 + if r.URL.Path != expectedPath { 86 + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 87 + } 88 + 89 + // Verify Authorization header 90 + auth := r.Header.Get("Authorization") 91 + if !strings.HasPrefix(auth, "Bearer ") { 92 + t.Errorf("Authorization header missing or malformed: %v", auth) 93 + } 94 + 95 + // Verify request body 96 + var body map[string]interface{} 97 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 98 + t.Errorf("Failed to decode request body: %v", err) 99 + } 100 + 101 + if body["repo"] != "did:plc:test123" { 102 + t.Errorf("repo = %v, want did:plc:test123", body["repo"]) 103 + } 104 + if body["collection"] != tt.collection { 105 + t.Errorf("collection = %v, want %v", body["collection"], tt.collection) 106 + } 107 + if body["rkey"] != tt.rkey { 108 + t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) 109 + } 110 + 111 + // Send response 112 + w.WriteHeader(tt.serverStatus) 113 + w.Write([]byte(tt.serverResponse)) 114 + })) 115 + defer server.Close() 116 + 117 + // Create client pointing to test server 118 + client := NewClient(server.URL, "did:plc:test123", "test-token") 119 + 120 + // Call PutRecord 121 + result, err := client.PutRecord(context.Background(), tt.collection, tt.rkey, tt.record) 122 + 123 + // Check error 124 + if (err != nil) != tt.wantErr { 125 + t.Errorf("PutRecord() error = %v, wantErr %v", err, tt.wantErr) 126 + return 127 + } 128 + 129 + // Run check function if provided 130 + if !tt.wantErr && tt.checkFunc != nil { 131 + tt.checkFunc(t, result) 132 + } 133 + }) 134 + } 135 + } 136 + 137 + // TestGetRecord tests retrieving a record from ATProto 138 + func TestGetRecord(t *testing.T) { 139 + tests := []struct { 140 + name string 141 + collection string 142 + rkey string 143 + serverResponse string 144 + serverStatus int 145 + wantErr bool 146 + wantNotFound bool 147 + checkFunc func(*testing.T, *Record) 148 + }{ 149 + { 150 + name: "successful get", 151 + collection: ManifestCollection, 152 + rkey: "abc123", 153 + serverResponse: `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{"$type":"io.atcr.manifest","repository":"myapp"}}`, 154 + serverStatus: http.StatusOK, 155 + wantErr: false, 156 + checkFunc: func(t *testing.T, r *Record) { 157 + if r.URI != "at://did:plc:test123/io.atcr.manifest/abc123" { 158 + t.Errorf("URI = %v, want at://did:plc:test123/io.atcr.manifest/abc123", r.URI) 159 + } 160 + 161 + var value map[string]interface{} 162 + if err := json.Unmarshal(r.Value, &value); err != nil { 163 + t.Errorf("Failed to unmarshal value: %v", err) 164 + } 165 + 166 + if value["$type"] != ManifestCollection { 167 + t.Errorf("value.$type = %v, want %v", value["$type"], ManifestCollection) 168 + } 169 + }, 170 + }, 171 + { 172 + name: "record not found - 404", 173 + collection: ManifestCollection, 174 + rkey: "notfound", 175 + serverResponse: ``, 176 + serverStatus: http.StatusNotFound, 177 + wantErr: true, 178 + wantNotFound: true, 179 + }, 180 + { 181 + name: "record not found - error message", 182 + collection: ManifestCollection, 183 + rkey: "notfound", 184 + serverResponse: `{"error":"RecordNotFound","message":"Record not found"}`, 185 + serverStatus: http.StatusBadRequest, 186 + wantErr: true, 187 + wantNotFound: true, 188 + }, 189 + } 190 + 191 + for _, tt := range tests { 192 + t.Run(tt.name, func(t *testing.T) { 193 + // Create test server 194 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 195 + // Verify request method 196 + if r.Method != "GET" { 197 + t.Errorf("Method = %v, want GET", r.Method) 198 + } 199 + 200 + // Verify path 201 + expectedPath := "/xrpc/com.atproto.repo.getRecord" 202 + if r.URL.Path != expectedPath { 203 + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 204 + } 205 + 206 + // Verify query parameters 207 + query := r.URL.Query() 208 + if query.Get("repo") != "did:plc:test123" { 209 + t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) 210 + } 211 + if query.Get("collection") != tt.collection { 212 + t.Errorf("collection = %v, want %v", query.Get("collection"), tt.collection) 213 + } 214 + if query.Get("rkey") != tt.rkey { 215 + t.Errorf("rkey = %v, want %v", query.Get("rkey"), tt.rkey) 216 + } 217 + 218 + // Send response 219 + w.WriteHeader(tt.serverStatus) 220 + w.Write([]byte(tt.serverResponse)) 221 + })) 222 + defer server.Close() 223 + 224 + // Create client pointing to test server 225 + client := NewClient(server.URL, "did:plc:test123", "test-token") 226 + 227 + // Call GetRecord 228 + result, err := client.GetRecord(context.Background(), tt.collection, tt.rkey) 229 + 230 + // Check error 231 + if (err != nil) != tt.wantErr { 232 + t.Errorf("GetRecord() error = %v, wantErr %v", err, tt.wantErr) 233 + return 234 + } 235 + 236 + // Check for ErrRecordNotFound 237 + if tt.wantNotFound && err != ErrRecordNotFound { 238 + t.Errorf("Expected ErrRecordNotFound, got %v", err) 239 + } 240 + 241 + // Run check function if provided 242 + if !tt.wantErr && tt.checkFunc != nil { 243 + tt.checkFunc(t, result) 244 + } 245 + }) 246 + } 247 + } 248 + 249 + // TestDeleteRecord tests deleting a record from ATProto 250 + func TestDeleteRecord(t *testing.T) { 251 + tests := []struct { 252 + name string 253 + collection string 254 + rkey string 255 + serverResponse string 256 + serverStatus int 257 + wantErr bool 258 + }{ 259 + { 260 + name: "successful delete", 261 + collection: ManifestCollection, 262 + rkey: "abc123", 263 + serverResponse: `{}`, 264 + serverStatus: http.StatusOK, 265 + wantErr: false, 266 + }, 267 + { 268 + name: "server error", 269 + collection: ManifestCollection, 270 + rkey: "abc123", 271 + serverResponse: `{"error":"InvalidRequest"}`, 272 + serverStatus: http.StatusBadRequest, 273 + wantErr: true, 274 + }, 275 + } 276 + 277 + for _, tt := range tests { 278 + t.Run(tt.name, func(t *testing.T) { 279 + // Create test server 280 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 281 + // Verify request method 282 + if r.Method != "POST" { 283 + t.Errorf("Method = %v, want POST", r.Method) 284 + } 285 + 286 + // Verify path 287 + expectedPath := "/xrpc/com.atproto.repo.deleteRecord" 288 + if r.URL.Path != expectedPath { 289 + t.Errorf("Path = %v, want %v", r.URL.Path, expectedPath) 290 + } 291 + 292 + // Verify request body 293 + var body map[string]interface{} 294 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 295 + t.Errorf("Failed to decode request body: %v", err) 296 + } 297 + 298 + if body["repo"] != "did:plc:test123" { 299 + t.Errorf("repo = %v, want did:plc:test123", body["repo"]) 300 + } 301 + if body["collection"] != tt.collection { 302 + t.Errorf("collection = %v, want %v", body["collection"], tt.collection) 303 + } 304 + if body["rkey"] != tt.rkey { 305 + t.Errorf("rkey = %v, want %v", body["rkey"], tt.rkey) 306 + } 307 + 308 + // Send response 309 + w.WriteHeader(tt.serverStatus) 310 + w.Write([]byte(tt.serverResponse)) 311 + })) 312 + defer server.Close() 313 + 314 + // Create client pointing to test server 315 + client := NewClient(server.URL, "did:plc:test123", "test-token") 316 + 317 + // Call DeleteRecord 318 + err := client.DeleteRecord(context.Background(), tt.collection, tt.rkey) 319 + 320 + // Check error 321 + if (err != nil) != tt.wantErr { 322 + t.Errorf("DeleteRecord() error = %v, wantErr %v", err, tt.wantErr) 323 + } 324 + }) 325 + } 326 + } 327 + 328 + // TestListRecords tests listing records in a collection 329 + func TestListRecords(t *testing.T) { 330 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 331 + // Verify query parameters 332 + query := r.URL.Query() 333 + if query.Get("repo") != "did:plc:test123" { 334 + t.Errorf("repo = %v, want did:plc:test123", query.Get("repo")) 335 + } 336 + if query.Get("collection") != ManifestCollection { 337 + t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) 338 + } 339 + if query.Get("limit") != "10" { 340 + t.Errorf("limit = %v, want 10", query.Get("limit")) 341 + } 342 + 343 + // Send response 344 + response := `{ 345 + "records": [ 346 + {"uri":"at://did:plc:test123/io.atcr.manifest/abc1","cid":"bafytest1","value":{"$type":"io.atcr.manifest"}}, 347 + {"uri":"at://did:plc:test123/io.atcr.manifest/abc2","cid":"bafytest2","value":{"$type":"io.atcr.manifest"}} 348 + ] 349 + }` 350 + w.WriteHeader(http.StatusOK) 351 + w.Write([]byte(response)) 352 + })) 353 + defer server.Close() 354 + 355 + client := NewClient(server.URL, "did:plc:test123", "test-token") 356 + records, err := client.ListRecords(context.Background(), ManifestCollection, 10) 357 + if err != nil { 358 + t.Fatalf("ListRecords() error = %v", err) 359 + } 360 + 361 + if len(records) != 2 { 362 + t.Errorf("len(records) = %v, want 2", len(records)) 363 + } 364 + 365 + if records[0].URI != "at://did:plc:test123/io.atcr.manifest/abc1" { 366 + t.Errorf("records[0].URI = %v", records[0].URI) 367 + } 368 + } 369 + 370 + // TestUploadBlob tests uploading a blob to PDS 371 + func TestUploadBlob(t *testing.T) { 372 + blobData := []byte("test blob content") 373 + mimeType := "application/octet-stream" 374 + 375 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 376 + // Verify request 377 + if r.Method != "POST" { 378 + t.Errorf("Method = %v, want POST", r.Method) 379 + } 380 + 381 + if r.URL.Path != "/xrpc/com.atproto.repo.uploadBlob" { 382 + t.Errorf("Path = %v, want /xrpc/com.atproto.repo.uploadBlob", r.URL.Path) 383 + } 384 + 385 + if r.Header.Get("Content-Type") != mimeType { 386 + t.Errorf("Content-Type = %v, want %v", r.Header.Get("Content-Type"), mimeType) 387 + } 388 + 389 + // Send response 390 + response := `{ 391 + "blob": { 392 + "$type": "blob", 393 + "ref": {"$link": "bafytest123"}, 394 + "mimeType": "application/octet-stream", 395 + "size": 17 396 + } 397 + }` 398 + w.WriteHeader(http.StatusOK) 399 + w.Write([]byte(response)) 400 + })) 401 + defer server.Close() 402 + 403 + client := NewClient(server.URL, "did:plc:test123", "test-token") 404 + blobRef, err := client.UploadBlob(context.Background(), blobData, mimeType) 405 + if err != nil { 406 + t.Fatalf("UploadBlob() error = %v", err) 407 + } 408 + 409 + if blobRef.Type != "blob" { 410 + t.Errorf("Type = %v, want blob", blobRef.Type) 411 + } 412 + 413 + if blobRef.Ref.Link != "bafytest123" { 414 + t.Errorf("Ref.Link = %v, want bafytest123", blobRef.Ref.Link) 415 + } 416 + 417 + if blobRef.Size != 17 { 418 + t.Errorf("Size = %v, want 17", blobRef.Size) 419 + } 420 + } 421 + 422 + // TestGetBlob tests downloading a blob from PDS 423 + func TestGetBlob(t *testing.T) { 424 + tests := []struct { 425 + name string 426 + cid string 427 + serverResponse string 428 + contentType string 429 + wantData []byte 430 + wantErr bool 431 + }{ 432 + { 433 + name: "raw blob response", 434 + cid: "bafytest123", 435 + serverResponse: "test blob content", 436 + contentType: "application/octet-stream", 437 + wantData: []byte("test blob content"), 438 + wantErr: false, 439 + }, 440 + { 441 + name: "JSON-wrapped blob (Bluesky PDS format)", 442 + cid: "bafytest123", 443 + serverResponse: `"dGVzdCBibG9iIGNvbnRlbnQ="`, // base64 of "test blob content" 444 + contentType: "application/json", 445 + wantData: []byte("test blob content"), 446 + wantErr: false, 447 + }, 448 + { 449 + name: "blob not found", 450 + cid: "notfound", 451 + serverResponse: "", 452 + contentType: "text/plain", 453 + wantData: nil, 454 + wantErr: true, 455 + }, 456 + } 457 + 458 + for _, tt := range tests { 459 + t.Run(tt.name, func(t *testing.T) { 460 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 461 + // Verify query parameters 462 + query := r.URL.Query() 463 + if query.Get("did") != "did:plc:test123" { 464 + t.Errorf("did = %v, want did:plc:test123", query.Get("did")) 465 + } 466 + if query.Get("cid") != tt.cid { 467 + t.Errorf("cid = %v, want %v", query.Get("cid"), tt.cid) 468 + } 469 + 470 + // Send response 471 + if tt.wantErr { 472 + w.WriteHeader(http.StatusNotFound) 473 + } else { 474 + w.Header().Set("Content-Type", tt.contentType) 475 + w.WriteHeader(http.StatusOK) 476 + w.Write([]byte(tt.serverResponse)) 477 + } 478 + })) 479 + defer server.Close() 480 + 481 + client := NewClient(server.URL, "did:plc:test123", "test-token") 482 + data, err := client.GetBlob(context.Background(), tt.cid) 483 + 484 + if (err != nil) != tt.wantErr { 485 + t.Errorf("GetBlob() error = %v, wantErr %v", err, tt.wantErr) 486 + return 487 + } 488 + 489 + if !tt.wantErr && string(data) != string(tt.wantData) { 490 + t.Errorf("GetBlob() data = %v, want %v", string(data), string(tt.wantData)) 491 + } 492 + }) 493 + } 494 + } 495 + 496 + // TestBlobCDNURL tests CDN URL construction 497 + func TestBlobCDNURL(t *testing.T) { 498 + tests := []struct { 499 + name string 500 + didOrHandle string 501 + cid string 502 + want string 503 + }{ 504 + { 505 + name: "with DID", 506 + didOrHandle: "did:plc:alice123", 507 + cid: "bafytest123", 508 + want: "https://imgs.blue/did:plc:alice123/bafytest123", 509 + }, 510 + { 511 + name: "with handle", 512 + didOrHandle: "alice.bsky.social", 513 + cid: "bafytest456", 514 + want: "https://imgs.blue/alice.bsky.social/bafytest456", 515 + }, 516 + } 517 + 518 + for _, tt := range tests { 519 + t.Run(tt.name, func(t *testing.T) { 520 + got := BlobCDNURL(tt.didOrHandle, tt.cid) 521 + if got != tt.want { 522 + t.Errorf("BlobCDNURL() = %v, want %v", got, tt.want) 523 + } 524 + }) 525 + } 526 + } 527 + 528 + // TestFetchDIDDocument tests fetching and parsing DID documents 529 + func TestFetchDIDDocument(t *testing.T) { 530 + tests := []struct { 531 + name string 532 + serverResponse string 533 + serverStatus int 534 + wantErr bool 535 + checkFunc func(*testing.T, *DIDDocument) 536 + }{ 537 + { 538 + name: "valid DID document", 539 + serverResponse: `{ 540 + "@context": ["https://www.w3.org/ns/did/v1"], 541 + "id": "did:web:example.com", 542 + "service": [ 543 + { 544 + "id": "#atproto_pds", 545 + "type": "AtprotoPersonalDataServer", 546 + "serviceEndpoint": "https://pds.example.com" 547 + } 548 + ] 549 + }`, 550 + serverStatus: http.StatusOK, 551 + wantErr: false, 552 + checkFunc: func(t *testing.T, doc *DIDDocument) { 553 + if doc.ID != "did:web:example.com" { 554 + t.Errorf("ID = %v, want did:web:example.com", doc.ID) 555 + } 556 + if len(doc.Service) != 1 { 557 + t.Fatalf("len(Service) = %v, want 1", len(doc.Service)) 558 + } 559 + if doc.Service[0].Type != "AtprotoPersonalDataServer" { 560 + t.Errorf("Service[0].Type = %v", doc.Service[0].Type) 561 + } 562 + if doc.Service[0].ServiceEndpoint != "https://pds.example.com" { 563 + t.Errorf("Service[0].ServiceEndpoint = %v", doc.Service[0].ServiceEndpoint) 564 + } 565 + }, 566 + }, 567 + { 568 + name: "404 not found", 569 + serverResponse: "", 570 + serverStatus: http.StatusNotFound, 571 + wantErr: true, 572 + }, 573 + { 574 + name: "invalid JSON", 575 + serverResponse: "not json", 576 + serverStatus: http.StatusOK, 577 + wantErr: true, 578 + }, 579 + } 580 + 581 + for _, tt := range tests { 582 + t.Run(tt.name, func(t *testing.T) { 583 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 584 + w.WriteHeader(tt.serverStatus) 585 + w.Write([]byte(tt.serverResponse)) 586 + })) 587 + defer server.Close() 588 + 589 + client := NewClient("https://pds.example.com", "did:plc:test123", "") 590 + doc, err := client.FetchDIDDocument(context.Background(), server.URL) 591 + 592 + if (err != nil) != tt.wantErr { 593 + t.Errorf("FetchDIDDocument() error = %v, wantErr %v", err, tt.wantErr) 594 + return 595 + } 596 + 597 + if !tt.wantErr && tt.checkFunc != nil { 598 + tt.checkFunc(t, doc) 599 + } 600 + }) 601 + } 602 + } 603 + 604 + // TestClientWithEmptyToken tests that client doesn't set auth header with empty token 605 + func TestClientWithEmptyToken(t *testing.T) { 606 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 607 + auth := r.Header.Get("Authorization") 608 + if auth != "" { 609 + t.Errorf("Authorization header should not be set with empty token, got: %v", auth) 610 + } 611 + 612 + response := `{"uri":"at://did:plc:test123/io.atcr.manifest/abc123","cid":"bafytest","value":{}}` 613 + w.WriteHeader(http.StatusOK) 614 + w.Write([]byte(response)) 615 + })) 616 + defer server.Close() 617 + 618 + // Create client with empty token 619 + client := NewClient(server.URL, "did:plc:test123", "") 620 + 621 + // Make request - should not include Authorization header 622 + _, err := client.GetRecord(context.Background(), ManifestCollection, "abc123") 623 + if err != nil { 624 + t.Fatalf("GetRecord() error = %v", err) 625 + } 626 + } 627 + 628 + // TestListRecordsForRepo tests listing records for a specific repository 629 + func TestListRecordsForRepo(t *testing.T) { 630 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 631 + query := r.URL.Query() 632 + if query.Get("repo") != "did:plc:alice123" { 633 + t.Errorf("repo = %v, want did:plc:alice123", query.Get("repo")) 634 + } 635 + if query.Get("collection") != ManifestCollection { 636 + t.Errorf("collection = %v, want %v", query.Get("collection"), ManifestCollection) 637 + } 638 + if query.Get("limit") != "50" { 639 + t.Errorf("limit = %v, want 50", query.Get("limit")) 640 + } 641 + if query.Get("cursor") != "cursor123" { 642 + t.Errorf("cursor = %v, want cursor123", query.Get("cursor")) 643 + } 644 + 645 + response := `{ 646 + "records": [ 647 + {"uri":"at://did:plc:alice123/io.atcr.manifest/abc1","cid":"bafytest1","value":{}} 648 + ], 649 + "cursor": "nextcursor456" 650 + }` 651 + w.WriteHeader(http.StatusOK) 652 + w.Write([]byte(response)) 653 + })) 654 + defer server.Close() 655 + 656 + client := NewClient(server.URL, "did:plc:test123", "test-token") 657 + records, cursor, err := client.ListRecordsForRepo(context.Background(), "did:plc:alice123", ManifestCollection, 50, "cursor123") 658 + 659 + if err != nil { 660 + t.Fatalf("ListRecordsForRepo() error = %v", err) 661 + } 662 + 663 + if len(records) != 1 { 664 + t.Errorf("len(records) = %v, want 1", len(records)) 665 + } 666 + 667 + if cursor != "nextcursor456" { 668 + t.Errorf("cursor = %v, want nextcursor456", cursor) 669 + } 670 + } 671 + 672 + // TestContextCancellation tests that client respects context cancellation 673 + func TestContextCancellation(t *testing.T) { 674 + // Create a server that sleeps for a while 675 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 676 + time.Sleep(100 * time.Millisecond) 677 + w.WriteHeader(http.StatusOK) 678 + w.Write([]byte(`{}`)) 679 + })) 680 + defer server.Close() 681 + 682 + client := NewClient(server.URL, "did:plc:test123", "test-token") 683 + 684 + // Create a context that gets canceled immediately 685 + ctx, cancel := context.WithCancel(context.Background()) 686 + cancel() // Cancel immediately 687 + 688 + // Request should fail with context canceled error 689 + _, err := client.GetRecord(ctx, ManifestCollection, "abc123") 690 + if err == nil { 691 + t.Error("Expected error due to context cancellation, got nil") 692 + } 693 + }
+139
pkg/atproto/endpoints.go
··· 1 + // Package xrpc provides constants for XRPC endpoint paths used throughout ATCR. 2 + // 3 + // This package serves as a single source of truth for all XRPC endpoint URLs, 4 + // preventing typos and making refactoring easier. All endpoint paths follow the 5 + // XRPC/Lexicon naming convention: /xrpc/{namespace}.{method} 6 + package atproto 7 + 8 + // Hold service multipart upload endpoints (io.atcr.hold.*) 9 + // 10 + // These endpoints handle OCI blob uploads to hold services (BYOS storage). 11 + const ( 12 + // HoldInitiateUpload starts a new multipart upload session. 13 + // Method: POST 14 + // Request: {"digest": "sha256:..."} 15 + // Response: {"uploadId": "..."} 16 + HoldInitiateUpload = "/xrpc/io.atcr.hold.initiateUpload" 17 + 18 + // HoldGetPartUploadUrl gets a presigned URL or endpoint info for uploading a specific part. 19 + // Method: POST 20 + // Request: {"uploadId": "...", "partNumber": 1} 21 + // Response: {"url": "...", "method": "PUT", "headers": {...}} 22 + HoldGetPartUploadUrl = "/xrpc/io.atcr.hold.getPartUploadUrl" 23 + 24 + // HoldUploadPart handles direct buffered part uploads (alternative to presigned URLs). 25 + // Method: PUT 26 + // Headers: X-Upload-Id, X-Part-Number 27 + // Body: raw part data 28 + // Response: {"etag": "..."} 29 + HoldUploadPart = "/xrpc/io.atcr.hold.uploadPart" 30 + 31 + // HoldCompleteUpload finalizes a multipart upload and moves blob to final location. 32 + // Method: POST 33 + // Request: {"uploadId": "...", "digest": "sha256:...", "parts": [{...}]} 34 + // Response: {"status": "completed", "digest": "..."} 35 + HoldCompleteUpload = "/xrpc/io.atcr.hold.completeUpload" 36 + 37 + // HoldAbortUpload cancels a multipart upload and cleans up temporary data. 38 + // Method: POST 39 + // Request: {"uploadId": "..."} 40 + // Response: {"status": "aborted"} 41 + HoldAbortUpload = "/xrpc/io.atcr.hold.abortUpload" 42 + ) 43 + 44 + // Hold service crew management endpoints (io.atcr.hold.*) 45 + // 46 + // These endpoints manage access control for hold services via crew membership. 47 + const ( 48 + // HoldRequestCrew requests crew membership for a hold service. 49 + // Method: POST 50 + // Request: OAuth-authenticated request with DPoP 51 + // Response: {"status": "pending"|"approved"} 52 + HoldRequestCrew = "/xrpc/io.atcr.hold.requestCrew" 53 + 54 + // Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess" 55 + ) 56 + 57 + // ATProto sync endpoints (com.atproto.sync.*) 58 + // 59 + // Standard AT Protocol synchronization endpoints for PDS interoperability. 60 + const ( 61 + // SyncGetBlob retrieves a blob (or presigned URL) from a repository. 62 + // Method: GET 63 + // Query: did={did}&cid={cid}&method={GET|HEAD} 64 + // Response: {"url": "..."} or blob data 65 + SyncGetBlob = "/xrpc/com.atproto.sync.getBlob" 66 + 67 + // SyncGetRepo downloads a full repository or diff as a CAR file. 68 + // Method: GET 69 + // Query: did={did}&since={rev} 70 + // Response: CAR file (application/vnd.ipld.car) 71 + SyncGetRepo = "/xrpc/com.atproto.sync.getRepo" 72 + 73 + // SyncListRepos lists all repositories on a PDS. 74 + // Method: GET 75 + // Response: {"repos": [{...}]} 76 + SyncListRepos = "/xrpc/com.atproto.sync.listRepos" 77 + 78 + // SyncSubscribeRepos subscribes to real-time repository events via WebSocket. 79 + // Method: GET (WebSocket upgrade) 80 + // Response: Stream of #commit events 81 + SyncSubscribeRepos = "/xrpc/com.atproto.sync.subscribeRepos" 82 + 83 + // SyncRequestCrawl requests a relay to crawl a PDS. 84 + // Method: POST 85 + // Request: {"hostname": "hold01.atcr.io"} 86 + // Response: {} 87 + SyncRequestCrawl = "/xrpc/com.atproto.sync.requestCrawl" 88 + ) 89 + 90 + // ATProto server endpoints (com.atproto.server.*) 91 + // 92 + // Standard AT Protocol server management and authentication endpoints. 93 + const ( 94 + // ServerGetServiceAuth gets a service auth token for inter-service communication. 95 + // Method: GET 96 + // Query: aud={serviceDID}&lxm={lexicon} 97 + // Response: {"token": "..."} 98 + ServerGetServiceAuth = "/xrpc/com.atproto.server.getServiceAuth" 99 + 100 + // ServerDescribeServer returns server metadata and capabilities. 101 + // Method: GET 102 + // Response: {"did": "...", "availableUserDomains": [...]} 103 + ServerDescribeServer = "/xrpc/com.atproto.server.describeServer" 104 + ) 105 + 106 + // ATProto repo endpoints (com.atproto.repo.*) 107 + // 108 + // Standard AT Protocol repository management endpoints. 109 + const ( 110 + // RepoDescribeRepo describes a repository's structure and metadata. 111 + // Method: GET 112 + // Query: repo={did} 113 + // Response: {"did": "...", "handle": "...", "collections": [...]} 114 + RepoDescribeRepo = "/xrpc/com.atproto.repo.describeRepo" 115 + 116 + // RepoDeleteRecord deletes a record from a repository. 117 + // Method: POST 118 + // Query: repo={did}&collection={collection}&rkey={key} 119 + // Response: {} 120 + RepoDeleteRecord = "/xrpc/com.atproto.repo.deleteRecord" 121 + 122 + // RepoUploadBlob uploads a blob to a repository (standard ATProto endpoint). 123 + // Method: POST 124 + // Body: blob data 125 + // Response: {"blob": {"$type": "blob", "ref": {...}, "mimeType": "...", "size": ...}} 126 + // Note: For OCI container layer uploads, ATCR uses io.atcr.hold.* multipart endpoints instead. 127 + RepoUploadBlob = "/xrpc/com.atproto.repo.uploadBlob" 128 + ) 129 + 130 + // ATProto identity endpoints (com.atproto.identity.*) 131 + // 132 + // Standard AT Protocol identity resolution endpoints. 133 + const ( 134 + // IdentityResolveHandle resolves a handle to a DID. 135 + // Method: GET 136 + // Query: handle={handle} 137 + // Response: {"did": "did:plc:..."} 138 + IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle" 139 + )
+88 -16
pkg/atproto/lexicon.go
··· 68 68 // SchemaVersion is the OCI schema version (typically 2) 69 69 SchemaVersion int `json:"schemaVersion"` 70 70 71 - // Config references the image configuration blob 72 - Config BlobReference `json:"config"` 71 + // Config references the image configuration blob (for image manifests) 72 + // Nil for manifest lists/indexes 73 + Config *BlobReference `json:"config,omitempty"` 73 74 74 - // Layers references the filesystem layers 75 - Layers []BlobReference `json:"layers"` 75 + // Layers references the filesystem layers (for image manifests) 76 + // Empty for manifest lists/indexes 77 + Layers []BlobReference `json:"layers,omitempty"` 78 + 79 + // Manifests references other manifests (for manifest lists/indexes) 80 + // Empty for image manifests 81 + Manifests []ManifestReference `json:"manifests,omitempty"` 76 82 77 83 // Annotations contains arbitrary metadata 78 84 Annotations map[string]string `json:"annotations,omitempty"` ··· 106 112 Annotations map[string]string `json:"annotations,omitempty"` 107 113 } 108 114 115 + // ManifestReference represents a reference to a manifest in a manifest list/index 116 + type ManifestReference struct { 117 + // MediaType of the referenced manifest 118 + MediaType string `json:"mediaType"` 119 + 120 + // Digest is the content digest (e.g., "sha256:abc123...") 121 + Digest string `json:"digest"` 122 + 123 + // Size in bytes 124 + Size int64 `json:"size"` 125 + 126 + // Platform describes the platform/architecture this manifest is for 127 + Platform *Platform `json:"platform,omitempty"` 128 + 129 + // Annotations for the manifest reference 130 + Annotations map[string]string `json:"annotations,omitempty"` 131 + } 132 + 133 + // Platform describes the platform (OS/architecture) for a manifest 134 + type Platform struct { 135 + // Architecture is the CPU architecture (e.g., "amd64", "arm64", "arm") 136 + Architecture string `json:"architecture"` 137 + 138 + // OS is the operating system (e.g., "linux", "windows", "darwin") 139 + OS string `json:"os"` 140 + 141 + // OSVersion is the optional OS version 142 + OSVersion string `json:"os.version,omitempty"` 143 + 144 + // OSFeatures is an optional list of OS features 145 + OSFeatures []string `json:"os.features,omitempty"` 146 + 147 + // Variant is the optional CPU variant (e.g., "v7" for ARM) 148 + Variant string `json:"variant,omitempty"` 149 + } 150 + 109 151 // NewManifestRecord creates a new manifest record from OCI manifest JSON 110 152 func NewManifestRecord(repository, digest string, ociManifest []byte) (*ManifestRecord, error) { 111 153 // Parse the OCI manifest 112 154 var ociData struct { 113 155 SchemaVersion int `json:"schemaVersion"` 114 156 MediaType string `json:"mediaType"` 115 - Config json.RawMessage `json:"config"` 116 - Layers []json.RawMessage `json:"layers"` 157 + Config json.RawMessage `json:"config,omitempty"` 158 + Layers []json.RawMessage `json:"layers,omitempty"` 159 + Manifests []json.RawMessage `json:"manifests,omitempty"` 117 160 Subject json.RawMessage `json:"subject,omitempty"` 118 161 Annotations map[string]string `json:"annotations,omitempty"` 119 162 } ··· 122 165 return nil, err 123 166 } 124 167 168 + // Detect manifest type based on media type 169 + isManifestList := strings.Contains(ociData.MediaType, "manifest.list") || 170 + strings.Contains(ociData.MediaType, "image.index") 171 + 172 + // Validate: must have either (config+layers) OR (manifests), never both 173 + hasImageFields := len(ociData.Config) > 0 || len(ociData.Layers) > 0 174 + hasIndexFields := len(ociData.Manifests) > 0 175 + 176 + if hasImageFields && hasIndexFields { 177 + return nil, fmt.Errorf("manifest cannot have both image fields (config/layers) and index fields (manifests)") 178 + } 179 + if !hasImageFields && !hasIndexFields { 180 + return nil, fmt.Errorf("manifest must have either image fields (config/layers) or index fields (manifests)") 181 + } 182 + 125 183 record := &ManifestRecord{ 126 184 Type: ManifestCollection, 127 185 Repository: repository, ··· 133 191 CreatedAt: time.Now(), 134 192 } 135 193 136 - // Parse config 137 - if err := json.Unmarshal(ociData.Config, &record.Config); err != nil { 138 - return nil, err 139 - } 194 + if isManifestList { 195 + // Parse manifest list/index 196 + record.Manifests = make([]ManifestReference, len(ociData.Manifests)) 197 + for i, m := range ociData.Manifests { 198 + if err := json.Unmarshal(m, &record.Manifests[i]); err != nil { 199 + return nil, fmt.Errorf("failed to parse manifest reference %d: %w", i, err) 200 + } 201 + } 202 + } else { 203 + // Parse image manifest 204 + if len(ociData.Config) > 0 { 205 + var config BlobReference 206 + if err := json.Unmarshal(ociData.Config, &config); err != nil { 207 + return nil, fmt.Errorf("failed to parse config: %w", err) 208 + } 209 + record.Config = &config 210 + } 140 211 141 - // Parse layers 142 - record.Layers = make([]BlobReference, len(ociData.Layers)) 143 - for i, layer := range ociData.Layers { 144 - if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { 145 - return nil, err 212 + // Parse layers 213 + record.Layers = make([]BlobReference, len(ociData.Layers)) 214 + for i, layer := range ociData.Layers { 215 + if err := json.Unmarshal(layer, &record.Layers[i]); err != nil { 216 + return nil, fmt.Errorf("failed to parse layer %d: %w", i, err) 217 + } 146 218 } 147 219 } 148 220 149 - // Parse subject if present 221 + // Parse subject if present (works for both types) 150 222 if len(ociData.Subject) > 0 { 151 223 var subject BlobReference 152 224 if err := json.Unmarshal(ociData.Subject, &subject); err != nil {
+112
pkg/atproto/lexicon_test.go
··· 49 49 } 50 50 }` 51 51 52 + manifestList := `{ 53 + "schemaVersion": 2, 54 + "mediaType": "application/vnd.oci.image.index.v1+json", 55 + "manifests": [ 56 + { 57 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 58 + "digest": "sha256:amd64manifest", 59 + "size": 1000, 60 + "platform": { 61 + "architecture": "amd64", 62 + "os": "linux" 63 + } 64 + }, 65 + { 66 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 67 + "digest": "sha256:arm64manifest", 68 + "size": 1100, 69 + "platform": { 70 + "architecture": "arm64", 71 + "os": "linux", 72 + "variant": "v8" 73 + } 74 + } 75 + ] 76 + }` 77 + 78 + invalidBothFields := `{ 79 + "schemaVersion": 2, 80 + "mediaType": "application/vnd.oci.image.index.v1+json", 81 + "config": { 82 + "mediaType": "application/vnd.oci.image.config.v1+json", 83 + "digest": "sha256:config123", 84 + "size": 1234 85 + }, 86 + "layers": [], 87 + "manifests": [ 88 + { 89 + "mediaType": "application/vnd.oci.image.manifest.v1+json", 90 + "digest": "sha256:amd64manifest", 91 + "size": 1000 92 + } 93 + ] 94 + }` 95 + 96 + invalidNoFields := `{ 97 + "schemaVersion": 2, 98 + "mediaType": "application/vnd.oci.image.manifest.v1+json" 99 + }` 100 + 52 101 tests := []struct { 53 102 name string 54 103 repository string ··· 135 184 repository: "myapp", 136 185 digest: "sha256:abc123", 137 186 ociManifest: `{"schemaVersion": 2, "mediaType": "test", "config": "not-an-object", "layers": []}`, 187 + wantErr: true, 188 + }, 189 + { 190 + name: "valid manifest list (multi-arch)", 191 + repository: "myapp", 192 + digest: "sha256:multiarch", 193 + ociManifest: manifestList, 194 + wantErr: false, 195 + checkFunc: func(t *testing.T, record *ManifestRecord) { 196 + if record.MediaType != "application/vnd.oci.image.index.v1+json" { 197 + t.Errorf("MediaType = %v, want application/vnd.oci.image.index.v1+json", record.MediaType) 198 + } 199 + if record.Config != nil { 200 + t.Error("Config should be nil for manifest list") 201 + } 202 + if len(record.Layers) != 0 { 203 + t.Errorf("Layers should be empty for manifest list, got %d", len(record.Layers)) 204 + } 205 + if len(record.Manifests) != 2 { 206 + t.Fatalf("Manifests should have 2 entries, got %d", len(record.Manifests)) 207 + } 208 + 209 + // Check first manifest (amd64) 210 + if record.Manifests[0].Digest != "sha256:amd64manifest" { 211 + t.Errorf("Manifests[0].Digest = %v, want sha256:amd64manifest", record.Manifests[0].Digest) 212 + } 213 + if record.Manifests[0].Size != 1000 { 214 + t.Errorf("Manifests[0].Size = %v, want 1000", record.Manifests[0].Size) 215 + } 216 + if record.Manifests[0].Platform == nil { 217 + t.Fatal("Manifests[0].Platform should not be nil") 218 + } 219 + if record.Manifests[0].Platform.Architecture != "amd64" { 220 + t.Errorf("Platform.Architecture = %v, want amd64", record.Manifests[0].Platform.Architecture) 221 + } 222 + if record.Manifests[0].Platform.OS != "linux" { 223 + t.Errorf("Platform.OS = %v, want linux", record.Manifests[0].Platform.OS) 224 + } 225 + 226 + // Check second manifest (arm64) 227 + if record.Manifests[1].Digest != "sha256:arm64manifest" { 228 + t.Errorf("Manifests[1].Digest = %v, want sha256:arm64manifest", record.Manifests[1].Digest) 229 + } 230 + if record.Manifests[1].Platform.Architecture != "arm64" { 231 + t.Errorf("Platform.Architecture = %v, want arm64", record.Manifests[1].Platform.Architecture) 232 + } 233 + if record.Manifests[1].Platform.Variant != "v8" { 234 + t.Errorf("Platform.Variant = %v, want v8", record.Manifests[1].Platform.Variant) 235 + } 236 + }, 237 + }, 238 + { 239 + name: "invalid: both image and index fields", 240 + repository: "myapp", 241 + digest: "sha256:invalid", 242 + ociManifest: invalidBothFields, 243 + wantErr: true, 244 + }, 245 + { 246 + name: "invalid: neither image nor index fields", 247 + repository: "myapp", 248 + digest: "sha256:invalid", 249 + ociManifest: invalidNoFields, 138 250 wantErr: true, 139 251 }, 140 252 }
+5 -1
pkg/atproto/manifest_store.go
··· 142 142 manifestRecord.HoldEndpoint = s.holdEndpoint // Legacy reference (URL) for backward compat 143 143 144 144 // Extract Dockerfile labels from config blob and add to annotations 145 - if s.blobStore != nil && manifestRecord.Config.Digest != "" { 145 + // Only for image manifests (not manifest lists which don't have config blobs) 146 + isManifestList := strings.Contains(manifestRecord.MediaType, "manifest.list") || 147 + strings.Contains(manifestRecord.MediaType, "image.index") 148 + 149 + if !isManifestList && s.blobStore != nil && manifestRecord.Config != nil && manifestRecord.Config.Digest != "" { 146 150 labels, err := s.extractConfigLabels(ctx, manifestRecord.Config.Digest) 147 151 if err != nil { 148 152 // Log error but don't fail the push - labels are optional
+518
pkg/atproto/manifest_store_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "net/http" 8 + "testing" 9 + 10 + "github.com/distribution/distribution/v3" 11 + "github.com/opencontainers/go-digest" 12 + ) 13 + 14 + // mockDatabaseMetrics is a mock implementation of DatabaseMetrics interface 15 + type mockDatabaseMetrics struct { 16 + pushCalls []pushCall 17 + pullCalls []pullCall 18 + } 19 + 20 + type pushCall struct { 21 + did string 22 + repository string 23 + } 24 + 25 + type pullCall struct { 26 + did string 27 + repository string 28 + } 29 + 30 + func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error { 31 + m.pushCalls = append(m.pushCalls, pushCall{did: did, repository: repository}) 32 + return nil 33 + } 34 + 35 + func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error { 36 + m.pullCalls = append(m.pullCalls, pullCall{did: did, repository: repository}) 37 + return nil 38 + } 39 + 40 + // mockBlobStore is a minimal mock of distribution.BlobStore for testing 41 + type mockBlobStore struct { 42 + blobs map[digest.Digest][]byte 43 + } 44 + 45 + func newMockBlobStore() *mockBlobStore { 46 + return &mockBlobStore{ 47 + blobs: make(map[digest.Digest][]byte), 48 + } 49 + } 50 + 51 + func (m *mockBlobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) { 52 + data, ok := m.blobs[dgst] 53 + if !ok { 54 + return nil, nil // Simplified: return nil for not found 55 + } 56 + return data, nil 57 + } 58 + 59 + // Implement remaining methods to satisfy distribution.BlobStore interface 60 + func (m *mockBlobStore) Put(ctx context.Context, mediaType string, p []byte) (distribution.Descriptor, error) { 61 + dgst := digest.FromBytes(p) 62 + m.blobs[dgst] = p 63 + return distribution.Descriptor{Digest: dgst, Size: int64(len(p)), MediaType: mediaType}, nil 64 + } 65 + 66 + func (m *mockBlobStore) Create(ctx context.Context, options ...distribution.BlobCreateOption) (distribution.BlobWriter, error) { 67 + return nil, nil // Not needed for current tests 68 + } 69 + 70 + func (m *mockBlobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) { 71 + return nil, nil // Not needed for current tests 72 + } 73 + 74 + func (m *mockBlobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error { 75 + return nil // Not needed for current tests 76 + } 77 + 78 + func (m *mockBlobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) { 79 + data, ok := m.blobs[dgst] 80 + if !ok { 81 + return distribution.Descriptor{}, distribution.ErrBlobUnknown 82 + } 83 + return distribution.Descriptor{Digest: dgst, Size: int64(len(data))}, nil 84 + } 85 + 86 + func (m *mockBlobStore) Delete(ctx context.Context, dgst digest.Digest) error { 87 + delete(m.blobs, dgst) 88 + return nil 89 + } 90 + 91 + func (m *mockBlobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) { 92 + return nil, nil // Not needed for current tests 93 + } 94 + 95 + // mockATProtoClient mocks the ATProto client for testing 96 + type mockATProtoClient struct { 97 + records map[string]map[string]interface{} // collection -> rkey -> record 98 + blobs map[string][]byte // cid -> blob data 99 + } 100 + 101 + func newMockATProtoClient() *mockATProtoClient { 102 + return &mockATProtoClient{ 103 + records: make(map[string]map[string]interface{}), 104 + blobs: make(map[string][]byte), 105 + } 106 + } 107 + 108 + // TestDigestToRKey tests digest to record key conversion 109 + func TestDigestToRKey(t *testing.T) { 110 + tests := []struct { 111 + name string 112 + digest digest.Digest 113 + want string 114 + }{ 115 + { 116 + name: "sha256 digest", 117 + digest: "sha256:abc123def456", 118 + want: "abc123def456", 119 + }, 120 + { 121 + name: "sha512 digest", 122 + digest: "sha512:xyz789", 123 + want: "xyz789", 124 + }, 125 + } 126 + 127 + for _, tt := range tests { 128 + t.Run(tt.name, func(t *testing.T) { 129 + got := digestToRKey(tt.digest) 130 + if got != tt.want { 131 + t.Errorf("digestToRKey() = %v, want %v", got, tt.want) 132 + } 133 + }) 134 + } 135 + } 136 + 137 + // TestRepositoryTagToRKey tests repository+tag to record key conversion 138 + func TestRepositoryTagToRKey(t *testing.T) { 139 + tests := []struct { 140 + name string 141 + repository string 142 + tag string 143 + want string 144 + }{ 145 + { 146 + name: "simple repo and tag", 147 + repository: "myapp", 148 + tag: "latest", 149 + want: "myapp_latest", 150 + }, 151 + { 152 + name: "repo with namespace", 153 + repository: "org/myapp", 154 + tag: "v1.0.0", 155 + want: "org-myapp_v1.0.0", 156 + }, 157 + { 158 + name: "tag with underscore", 159 + repository: "myapp", 160 + tag: "test_tag", 161 + want: "myapp_test_tag", 162 + }, 163 + { 164 + name: "deep namespace", 165 + repository: "a/b/c/myapp", 166 + tag: "prod", 167 + want: "a-b-c-myapp_prod", 168 + }, 169 + } 170 + 171 + for _, tt := range tests { 172 + t.Run(tt.name, func(t *testing.T) { 173 + got := repositoryTagToRKey(tt.repository, tt.tag) 174 + if got != tt.want { 175 + t.Errorf("repositoryTagToRKey() = %v, want %v", got, tt.want) 176 + } 177 + }) 178 + } 179 + } 180 + 181 + // TestRKeyToRepositoryTag tests converting record key back to repository and tag 182 + func TestRKeyToRepositoryTag(t *testing.T) { 183 + tests := []struct { 184 + name string 185 + rkey string 186 + wantRepository string 187 + wantTag string 188 + }{ 189 + { 190 + name: "simple key", 191 + rkey: "myapp_latest", 192 + wantRepository: "myapp", 193 + wantTag: "latest", 194 + }, 195 + { 196 + name: "namespaced repo", 197 + rkey: "org-myapp_v1.0.0", 198 + wantRepository: "org/myapp", 199 + wantTag: "v1.0.0", 200 + }, 201 + { 202 + name: "tag with underscore (splits on last underscore)", 203 + rkey: "myapp_test_tag", 204 + wantRepository: "myapp_test", 205 + wantTag: "tag", 206 + }, 207 + { 208 + name: "deep namespace", 209 + rkey: "a-b-c-myapp_prod", 210 + wantRepository: "a/b/c/myapp", 211 + wantTag: "prod", 212 + }, 213 + { 214 + name: "no underscore - all tag", 215 + rkey: "latest", 216 + wantRepository: "", 217 + wantTag: "latest", 218 + }, 219 + } 220 + 221 + for _, tt := range tests { 222 + t.Run(tt.name, func(t *testing.T) { 223 + gotRepo, gotTag := RKeyToRepositoryTag(tt.rkey) 224 + if gotRepo != tt.wantRepository { 225 + t.Errorf("RKeyToRepositoryTag() repository = %v, want %v", gotRepo, tt.wantRepository) 226 + } 227 + if gotTag != tt.wantTag { 228 + t.Errorf("RKeyToRepositoryTag() tag = %v, want %v", gotTag, tt.wantTag) 229 + } 230 + }) 231 + } 232 + } 233 + 234 + // TestRepositoryTagRoundTrip tests that converting to rkey and back preserves values 235 + // Note: Tags with underscores cannot be perfectly round-tripped since we use underscore as separator 236 + func TestRepositoryTagRoundTrip(t *testing.T) { 237 + tests := []struct { 238 + repository string 239 + tag string 240 + }{ 241 + {"myapp", "latest"}, 242 + {"org/myapp", "v1.0.0"}, 243 + {"a/b/c/myapp", "prod"}, 244 + // Note: Tags with underscores are excluded - they cannot round-trip correctly 245 + // because underscore is used as the separator between repository and tag 246 + } 247 + 248 + for _, tt := range tests { 249 + t.Run(tt.repository+":"+tt.tag, func(t *testing.T) { 250 + rkey := repositoryTagToRKey(tt.repository, tt.tag) 251 + gotRepo, gotTag := RKeyToRepositoryTag(rkey) 252 + 253 + if gotRepo != tt.repository { 254 + t.Errorf("Round trip failed: repository = %v, want %v", gotRepo, tt.repository) 255 + } 256 + if gotTag != tt.tag { 257 + t.Errorf("Round trip failed: tag = %v, want %v", gotTag, tt.tag) 258 + } 259 + }) 260 + } 261 + } 262 + 263 + // TestNewManifestStore tests creating a new manifest store 264 + func TestNewManifestStore(t *testing.T) { 265 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 266 + blobStore := newMockBlobStore() 267 + db := &mockDatabaseMetrics{} 268 + 269 + store := NewManifestStore( 270 + client, 271 + "myapp", 272 + "https://hold.example.com", 273 + "did:web:hold.example.com", 274 + "did:plc:alice123", 275 + blobStore, 276 + db, 277 + ) 278 + 279 + if store.repository != "myapp" { 280 + t.Errorf("repository = %v, want myapp", store.repository) 281 + } 282 + if store.holdEndpoint != "https://hold.example.com" { 283 + t.Errorf("holdEndpoint = %v, want https://hold.example.com", store.holdEndpoint) 284 + } 285 + if store.holdDID != "did:web:hold.example.com" { 286 + t.Errorf("holdDID = %v, want did:web:hold.example.com", store.holdDID) 287 + } 288 + if store.did != "did:plc:alice123" { 289 + t.Errorf("did = %v, want did:plc:alice123", store.did) 290 + } 291 + } 292 + 293 + // TestManifestStore_GetLastFetchedHoldDID tests tracking last fetched hold DID 294 + func TestManifestStore_GetLastFetchedHoldDID(t *testing.T) { 295 + tests := []struct { 296 + name string 297 + manifestHoldDID string 298 + manifestHoldURL string 299 + expectedLastFetched string 300 + }{ 301 + { 302 + name: "prefers HoldDID", 303 + manifestHoldDID: "did:web:hold01.atcr.io", 304 + manifestHoldURL: "https://hold01.atcr.io", 305 + expectedLastFetched: "did:web:hold01.atcr.io", 306 + }, 307 + { 308 + name: "falls back to HoldEndpoint URL conversion", 309 + manifestHoldDID: "", 310 + manifestHoldURL: "https://hold02.atcr.io", 311 + expectedLastFetched: "did:web:hold02.atcr.io", 312 + }, 313 + { 314 + name: "empty hold references", 315 + manifestHoldDID: "", 316 + manifestHoldURL: "", 317 + expectedLastFetched: "", 318 + }, 319 + } 320 + 321 + for _, tt := range tests { 322 + t.Run(tt.name, func(t *testing.T) { 323 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 324 + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", nil, nil) 325 + 326 + // Simulate what happens in Get() when parsing a manifest record 327 + var manifestRecord ManifestRecord 328 + manifestRecord.HoldDID = tt.manifestHoldDID 329 + manifestRecord.HoldEndpoint = tt.manifestHoldURL 330 + 331 + // Mimic the hold DID extraction logic from Get() 332 + if manifestRecord.HoldDID != "" { 333 + store.lastFetchedHoldDID = manifestRecord.HoldDID 334 + } else if manifestRecord.HoldEndpoint != "" { 335 + store.lastFetchedHoldDID = ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) 336 + } 337 + 338 + got := store.GetLastFetchedHoldDID() 339 + if got != tt.expectedLastFetched { 340 + t.Errorf("GetLastFetchedHoldDID() = %v, want %v", got, tt.expectedLastFetched) 341 + } 342 + }) 343 + } 344 + } 345 + 346 + // TestRawManifest tests the rawManifest implementation 347 + func TestRawManifest(t *testing.T) { 348 + mediaType := "application/vnd.oci.image.manifest.v1+json" 349 + payload := []byte(`{"schemaVersion":2}`) 350 + 351 + manifest := &rawManifest{ 352 + mediaType: mediaType, 353 + payload: payload, 354 + } 355 + 356 + // Test Payload() 357 + gotMediaType, gotPayload, err := manifest.Payload() 358 + if err != nil { 359 + t.Fatalf("Payload() error = %v", err) 360 + } 361 + 362 + if gotMediaType != mediaType { 363 + t.Errorf("Payload() mediaType = %v, want %v", gotMediaType, mediaType) 364 + } 365 + 366 + if string(gotPayload) != string(payload) { 367 + t.Errorf("Payload() payload = %v, want %v", string(gotPayload), string(payload)) 368 + } 369 + 370 + // Test References() - should return nil for now 371 + refs := manifest.References() 372 + if refs != nil { 373 + t.Errorf("References() = %v, want nil", refs) 374 + } 375 + } 376 + 377 + // TestExtractConfigLabels tests extracting labels from image config 378 + func TestExtractConfigLabels(t *testing.T) { 379 + // Create a mock config blob 380 + configJSON := map[string]interface{}{ 381 + "config": map[string]interface{}{ 382 + "Labels": map[string]string{ 383 + "org.opencontainers.image.version": "1.0.0", 384 + "org.opencontainers.image.authors": "test@example.com", 385 + "custom.label": "value", 386 + }, 387 + }, 388 + } 389 + configData, _ := json.Marshal(configJSON) 390 + 391 + // Create blob store with config 392 + blobStore := newMockBlobStore() 393 + configDigest := digest.FromBytes(configData) 394 + blobStore.blobs[configDigest] = configData 395 + 396 + // Create manifest store 397 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 398 + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) 399 + 400 + // Extract labels 401 + labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) 402 + if err != nil { 403 + t.Fatalf("extractConfigLabels() error = %v", err) 404 + } 405 + 406 + // Verify labels 407 + expectedLabels := map[string]string{ 408 + "org.opencontainers.image.version": "1.0.0", 409 + "org.opencontainers.image.authors": "test@example.com", 410 + "custom.label": "value", 411 + } 412 + 413 + if len(labels) != len(expectedLabels) { 414 + t.Errorf("len(labels) = %v, want %v", len(labels), len(expectedLabels)) 415 + } 416 + 417 + for key, expectedValue := range expectedLabels { 418 + if labels[key] != expectedValue { 419 + t.Errorf("labels[%s] = %v, want %v", key, labels[key], expectedValue) 420 + } 421 + } 422 + } 423 + 424 + // TestExtractConfigLabels_NoLabels tests handling config without labels 425 + func TestExtractConfigLabels_NoLabels(t *testing.T) { 426 + // Config without Labels field 427 + configJSON := map[string]interface{}{ 428 + "config": map[string]interface{}{}, 429 + } 430 + configData, _ := json.Marshal(configJSON) 431 + 432 + blobStore := newMockBlobStore() 433 + configDigest := digest.FromBytes(configData) 434 + blobStore.blobs[configDigest] = configData 435 + 436 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 437 + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) 438 + 439 + labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) 440 + if err != nil { 441 + t.Fatalf("extractConfigLabels() error = %v", err) 442 + } 443 + 444 + // Should return empty map (or nil) 445 + if labels != nil && len(labels) != 0 { 446 + t.Errorf("extractConfigLabels() should return empty/nil for config without labels, got %v", labels) 447 + } 448 + } 449 + 450 + // TestExtractConfigLabels_InvalidDigest tests error handling for invalid digest 451 + func TestExtractConfigLabels_InvalidDigest(t *testing.T) { 452 + blobStore := newMockBlobStore() 453 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 454 + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) 455 + 456 + _, err := store.extractConfigLabels(context.Background(), "invalid-digest") 457 + if err == nil { 458 + t.Error("extractConfigLabels() should return error for invalid digest") 459 + } 460 + } 461 + 462 + // TestExtractConfigLabels_InvalidJSON tests handling of malformed config JSON 463 + func TestExtractConfigLabels_InvalidJSON(t *testing.T) { 464 + // Invalid JSON 465 + configData := []byte("not valid json") 466 + 467 + blobStore := newMockBlobStore() 468 + configDigest := digest.FromBytes(configData) 469 + blobStore.blobs[configDigest] = configData 470 + 471 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 472 + store := NewManifestStore(client, "myapp", "", "", "did:plc:test123", blobStore, nil) 473 + 474 + _, err := store.extractConfigLabels(context.Background(), configDigest.String()) 475 + if err == nil { 476 + t.Error("extractConfigLabels() should return error for invalid JSON") 477 + } 478 + } 479 + 480 + // TestManifestStore_WithMetrics tests that metrics are tracked 481 + func TestManifestStore_WithMetrics(t *testing.T) { 482 + db := &mockDatabaseMetrics{} 483 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 484 + store := NewManifestStore( 485 + client, 486 + "myapp", 487 + "https://hold.example.com", 488 + "did:web:hold.example.com", 489 + "did:plc:alice123", 490 + nil, 491 + db, 492 + ) 493 + 494 + if store.database != db { 495 + t.Error("ManifestStore should store database reference") 496 + } 497 + 498 + // Note: Actual metrics tracking happens in Put() and Get() which require 499 + // full mock setup. The important thing is that the database is wired up. 500 + } 501 + 502 + // TestManifestStore_WithoutMetrics tests that nil database is acceptable 503 + func TestManifestStore_WithoutMetrics(t *testing.T) { 504 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 505 + store := NewManifestStore( 506 + client, 507 + "myapp", 508 + "https://hold.example.com", 509 + "did:web:hold.example.com", 510 + "did:plc:alice123", 511 + nil, 512 + nil, // nil database 513 + ) 514 + 515 + if store.database != nil { 516 + t.Error("ManifestStore should accept nil database") 517 + } 518 + }
+558
pkg/atproto/profile_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "sync" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + // TestEnsureProfile_Create tests creating a new profile when one doesn't exist 15 + func TestEnsureProfile_Create(t *testing.T) { 16 + tests := []struct { 17 + name string 18 + defaultHoldDID string 19 + wantNormalized string // Expected defaultHold value after normalization 20 + }{ 21 + { 22 + name: "with DID", 23 + defaultHoldDID: "did:web:hold01.atcr.io", 24 + wantNormalized: "did:web:hold01.atcr.io", 25 + }, 26 + { 27 + name: "with URL - should normalize to DID", 28 + defaultHoldDID: "https://hold01.atcr.io", 29 + wantNormalized: "did:web:hold01.atcr.io", 30 + }, 31 + { 32 + name: "empty default hold", 33 + defaultHoldDID: "", 34 + wantNormalized: "", 35 + }, 36 + } 37 + 38 + for _, tt := range tests { 39 + t.Run(tt.name, func(t *testing.T) { 40 + var createdProfile *SailorProfileRecord 41 + 42 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 43 + // First request: GetRecord (should 404) 44 + if r.Method == "GET" { 45 + w.WriteHeader(http.StatusNotFound) 46 + return 47 + } 48 + 49 + // Second request: PutRecord (create profile) 50 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 51 + var body map[string]interface{} 52 + json.NewDecoder(r.Body).Decode(&body) 53 + 54 + // Verify profile data 55 + recordData := body["record"].(map[string]interface{}) 56 + if recordData["$type"] != SailorProfileCollection { 57 + t.Errorf("$type = %v, want %v", recordData["$type"], SailorProfileCollection) 58 + } 59 + 60 + // Check defaultHold normalization 61 + defaultHold := recordData["defaultHold"] 62 + // Handle empty string (may be nil in JSON) 63 + defaultHoldStr := "" 64 + if defaultHold != nil { 65 + defaultHoldStr = defaultHold.(string) 66 + } 67 + if defaultHoldStr != tt.wantNormalized { 68 + t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized) 69 + } 70 + 71 + // Store for later verification 72 + profileBytes, _ := json.Marshal(recordData) 73 + json.Unmarshal(profileBytes, &createdProfile) 74 + 75 + w.WriteHeader(http.StatusOK) 76 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 77 + return 78 + } 79 + 80 + w.WriteHeader(http.StatusBadRequest) 81 + })) 82 + defer server.Close() 83 + 84 + client := NewClient(server.URL, "did:plc:test123", "test-token") 85 + err := EnsureProfile(context.Background(), client, tt.defaultHoldDID) 86 + 87 + if err != nil { 88 + t.Fatalf("EnsureProfile() error = %v", err) 89 + } 90 + 91 + // Verify created profile 92 + if createdProfile == nil { 93 + t.Fatal("Profile was not created") 94 + } 95 + 96 + if createdProfile.Type != SailorProfileCollection { 97 + t.Errorf("Type = %v, want %v", createdProfile.Type, SailorProfileCollection) 98 + } 99 + 100 + if createdProfile.DefaultHold != tt.wantNormalized { 101 + t.Errorf("DefaultHold = %v, want %v", createdProfile.DefaultHold, tt.wantNormalized) 102 + } 103 + }) 104 + } 105 + } 106 + 107 + // TestEnsureProfile_Exists tests that EnsureProfile doesn't recreate existing profiles 108 + func TestEnsureProfile_Exists(t *testing.T) { 109 + putRecordCalled := false 110 + 111 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 + // GetRecord: profile exists 113 + if r.Method == "GET" { 114 + response := `{ 115 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 116 + "cid": "bafytest", 117 + "value": { 118 + "$type": "io.atcr.sailor.profile", 119 + "defaultHold": "did:web:hold01.atcr.io", 120 + "createdAt": "2025-01-01T00:00:00Z", 121 + "updatedAt": "2025-01-01T00:00:00Z" 122 + } 123 + }` 124 + w.WriteHeader(http.StatusOK) 125 + w.Write([]byte(response)) 126 + return 127 + } 128 + 129 + // PutRecord: should not be called 130 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 131 + putRecordCalled = true 132 + t.Error("PutRecord should not be called when profile exists") 133 + } 134 + })) 135 + defer server.Close() 136 + 137 + client := NewClient(server.URL, "did:plc:test123", "test-token") 138 + err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") 139 + 140 + if err != nil { 141 + t.Fatalf("EnsureProfile() error = %v", err) 142 + } 143 + 144 + if putRecordCalled { 145 + t.Error("PutRecord was called when profile already exists") 146 + } 147 + } 148 + 149 + // TestGetProfile tests retrieving a user's profile 150 + func TestGetProfile(t *testing.T) { 151 + tests := []struct { 152 + name string 153 + serverResponse string 154 + serverStatus int 155 + wantProfile *SailorProfileRecord 156 + wantNil bool 157 + wantErr bool 158 + expectMigration bool // Whether URL-to-DID migration should happen 159 + originalHoldURL string 160 + expectedHoldDID string 161 + }{ 162 + { 163 + name: "profile with DID (no migration needed)", 164 + serverResponse: `{ 165 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 166 + "value": { 167 + "$type": "io.atcr.sailor.profile", 168 + "defaultHold": "did:web:hold01.atcr.io", 169 + "createdAt": "2025-01-01T00:00:00Z", 170 + "updatedAt": "2025-01-01T00:00:00Z" 171 + } 172 + }`, 173 + serverStatus: http.StatusOK, 174 + wantNil: false, 175 + wantErr: false, 176 + expectMigration: false, 177 + expectedHoldDID: "did:web:hold01.atcr.io", 178 + }, 179 + { 180 + name: "profile with URL (migration needed)", 181 + serverResponse: `{ 182 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 183 + "value": { 184 + "$type": "io.atcr.sailor.profile", 185 + "defaultHold": "https://hold01.atcr.io", 186 + "createdAt": "2025-01-01T00:00:00Z", 187 + "updatedAt": "2025-01-01T00:00:00Z" 188 + } 189 + }`, 190 + serverStatus: http.StatusOK, 191 + wantNil: false, 192 + wantErr: false, 193 + expectMigration: true, 194 + originalHoldURL: "https://hold01.atcr.io", 195 + expectedHoldDID: "did:web:hold01.atcr.io", 196 + }, 197 + { 198 + name: "profile doesn't exist - return nil", 199 + serverResponse: "", 200 + serverStatus: http.StatusNotFound, 201 + wantNil: true, 202 + wantErr: false, 203 + expectMigration: false, 204 + }, 205 + { 206 + name: "server error", 207 + serverResponse: `{"error":"InternalServerError"}`, 208 + serverStatus: http.StatusInternalServerError, 209 + wantNil: false, 210 + wantErr: true, 211 + expectMigration: false, 212 + }, 213 + } 214 + 215 + for _, tt := range tests { 216 + t.Run(tt.name, func(t *testing.T) { 217 + // Clear migration locks before each test 218 + migrationLocks = sync.Map{} 219 + 220 + putRecordCalled := false 221 + var migrationRequest map[string]interface{} 222 + 223 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 224 + // GetRecord 225 + if r.Method == "GET" { 226 + w.WriteHeader(tt.serverStatus) 227 + w.Write([]byte(tt.serverResponse)) 228 + return 229 + } 230 + 231 + // PutRecord (migration) 232 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 233 + putRecordCalled = true 234 + json.NewDecoder(r.Body).Decode(&migrationRequest) 235 + w.WriteHeader(http.StatusOK) 236 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 237 + return 238 + } 239 + })) 240 + defer server.Close() 241 + 242 + client := NewClient(server.URL, "did:plc:test123", "test-token") 243 + profile, err := GetProfile(context.Background(), client) 244 + 245 + if (err != nil) != tt.wantErr { 246 + t.Errorf("GetProfile() error = %v, wantErr %v", err, tt.wantErr) 247 + return 248 + } 249 + 250 + if tt.wantNil { 251 + if profile != nil { 252 + t.Errorf("GetProfile() = %v, want nil", profile) 253 + } 254 + return 255 + } 256 + 257 + if !tt.wantErr { 258 + if profile == nil { 259 + t.Fatal("GetProfile() returned nil, want profile") 260 + } 261 + 262 + // Check that defaultHold is migrated to DID in returned profile 263 + if profile.DefaultHold != tt.expectedHoldDID { 264 + t.Errorf("DefaultHold = %v, want %v", profile.DefaultHold, tt.expectedHoldDID) 265 + } 266 + 267 + if tt.expectMigration { 268 + // Give goroutine time to execute 269 + time.Sleep(50 * time.Millisecond) 270 + 271 + if !putRecordCalled { 272 + t.Error("Expected migration PutRecord to be called") 273 + } 274 + 275 + if migrationRequest != nil { 276 + recordData := migrationRequest["record"].(map[string]interface{}) 277 + migratedHold := recordData["defaultHold"] 278 + if migratedHold != tt.expectedHoldDID { 279 + t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID) 280 + } 281 + } 282 + } 283 + } 284 + }) 285 + } 286 + } 287 + 288 + // TestGetProfile_MigrationLocking tests that concurrent migrations don't happen 289 + func TestGetProfile_MigrationLocking(t *testing.T) { 290 + // Clear migration locks 291 + migrationLocks = sync.Map{} 292 + 293 + putRecordCount := 0 294 + var mu sync.Mutex 295 + 296 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 297 + // GetRecord - return profile with URL 298 + if r.Method == "GET" { 299 + response := `{ 300 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 301 + "value": { 302 + "$type": "io.atcr.sailor.profile", 303 + "defaultHold": "https://hold01.atcr.io", 304 + "createdAt": "2025-01-01T00:00:00Z", 305 + "updatedAt": "2025-01-01T00:00:00Z" 306 + } 307 + }` 308 + w.WriteHeader(http.StatusOK) 309 + w.Write([]byte(response)) 310 + return 311 + } 312 + 313 + // PutRecord - count migrations 314 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 315 + mu.Lock() 316 + putRecordCount++ 317 + mu.Unlock() 318 + 319 + // Add small delay to ensure concurrent requests 320 + time.Sleep(10 * time.Millisecond) 321 + 322 + w.WriteHeader(http.StatusOK) 323 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 324 + return 325 + } 326 + })) 327 + defer server.Close() 328 + 329 + client := NewClient(server.URL, "did:plc:test123", "test-token") 330 + 331 + // Make 5 concurrent GetProfile calls 332 + var wg sync.WaitGroup 333 + for i := 0; i < 5; i++ { 334 + wg.Add(1) 335 + go func() { 336 + defer wg.Done() 337 + _, err := GetProfile(context.Background(), client) 338 + if err != nil { 339 + t.Errorf("GetProfile() error = %v", err) 340 + } 341 + }() 342 + } 343 + 344 + wg.Wait() 345 + 346 + // Give migrations time to complete 347 + time.Sleep(200 * time.Millisecond) 348 + 349 + // Only one migration should have been persisted due to locking 350 + mu.Lock() 351 + count := putRecordCount 352 + mu.Unlock() 353 + 354 + if count != 1 { 355 + t.Errorf("PutRecord called %d times, want 1 (locking should prevent concurrent migrations)", count) 356 + } 357 + } 358 + 359 + // TestUpdateProfile tests updating a user's profile 360 + func TestUpdateProfile(t *testing.T) { 361 + tests := []struct { 362 + name string 363 + profile *SailorProfileRecord 364 + wantNormalized string // Expected defaultHold after normalization 365 + wantErr bool 366 + }{ 367 + { 368 + name: "update with DID", 369 + profile: &SailorProfileRecord{ 370 + Type: SailorProfileCollection, 371 + DefaultHold: "did:web:hold02.atcr.io", 372 + CreatedAt: time.Now(), 373 + UpdatedAt: time.Now(), 374 + }, 375 + wantNormalized: "did:web:hold02.atcr.io", 376 + wantErr: false, 377 + }, 378 + { 379 + name: "update with URL - should normalize", 380 + profile: &SailorProfileRecord{ 381 + Type: SailorProfileCollection, 382 + DefaultHold: "https://hold02.atcr.io", 383 + CreatedAt: time.Now(), 384 + UpdatedAt: time.Now(), 385 + }, 386 + wantNormalized: "did:web:hold02.atcr.io", 387 + wantErr: false, 388 + }, 389 + { 390 + name: "clear default hold", 391 + profile: &SailorProfileRecord{ 392 + Type: SailorProfileCollection, 393 + DefaultHold: "", 394 + CreatedAt: time.Now(), 395 + UpdatedAt: time.Now(), 396 + }, 397 + wantNormalized: "", 398 + wantErr: false, 399 + }, 400 + } 401 + 402 + for _, tt := range tests { 403 + t.Run(tt.name, func(t *testing.T) { 404 + var sentProfile map[string]interface{} 405 + 406 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 407 + if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") { 408 + var body map[string]interface{} 409 + json.NewDecoder(r.Body).Decode(&body) 410 + sentProfile = body 411 + 412 + // Verify rkey is "self" 413 + if body["rkey"] != ProfileRKey { 414 + t.Errorf("rkey = %v, want %v", body["rkey"], ProfileRKey) 415 + } 416 + 417 + w.WriteHeader(http.StatusOK) 418 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`)) 419 + return 420 + } 421 + w.WriteHeader(http.StatusBadRequest) 422 + })) 423 + defer server.Close() 424 + 425 + client := NewClient(server.URL, "did:plc:test123", "test-token") 426 + err := UpdateProfile(context.Background(), client, tt.profile) 427 + 428 + if (err != nil) != tt.wantErr { 429 + t.Errorf("UpdateProfile() error = %v, wantErr %v", err, tt.wantErr) 430 + return 431 + } 432 + 433 + if !tt.wantErr { 434 + // Verify normalization happened 435 + recordData := sentProfile["record"].(map[string]interface{}) 436 + defaultHold := recordData["defaultHold"] 437 + // Handle empty string (may be nil in JSON) 438 + defaultHoldStr := "" 439 + if defaultHold != nil { 440 + defaultHoldStr = defaultHold.(string) 441 + } 442 + if defaultHoldStr != tt.wantNormalized { 443 + t.Errorf("defaultHold = %v, want %v", defaultHoldStr, tt.wantNormalized) 444 + } 445 + 446 + // Verify normalization also updated the profile object 447 + if tt.profile.DefaultHold != tt.wantNormalized { 448 + t.Errorf("profile.DefaultHold = %v, want %v (should be updated in-place)", tt.profile.DefaultHold, tt.wantNormalized) 449 + } 450 + } 451 + }) 452 + } 453 + } 454 + 455 + // TestProfileRKey tests that profile record key is always "self" 456 + func TestProfileRKey(t *testing.T) { 457 + if ProfileRKey != "self" { 458 + t.Errorf("ProfileRKey = %v, want self", ProfileRKey) 459 + } 460 + } 461 + 462 + // TestEnsureProfile_Error tests error handling during profile creation 463 + func TestEnsureProfile_Error(t *testing.T) { 464 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 465 + // GetRecord: profile doesn't exist 466 + if r.Method == "GET" { 467 + w.WriteHeader(http.StatusNotFound) 468 + return 469 + } 470 + 471 + // PutRecord: fail with server error 472 + if r.Method == "POST" { 473 + w.WriteHeader(http.StatusInternalServerError) 474 + w.Write([]byte(`{"error":"InternalServerError"}`)) 475 + return 476 + } 477 + })) 478 + defer server.Close() 479 + 480 + client := NewClient(server.URL, "did:plc:test123", "test-token") 481 + err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io") 482 + 483 + if err == nil { 484 + t.Error("EnsureProfile() should return error when PutRecord fails") 485 + } 486 + } 487 + 488 + // TestGetProfile_InvalidJSON tests handling of invalid profile JSON 489 + func TestGetProfile_InvalidJSON(t *testing.T) { 490 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 491 + response := `{ 492 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 493 + "value": "not-valid-json-object" 494 + }` 495 + w.WriteHeader(http.StatusOK) 496 + w.Write([]byte(response)) 497 + })) 498 + defer server.Close() 499 + 500 + client := NewClient(server.URL, "did:plc:test123", "test-token") 501 + _, err := GetProfile(context.Background(), client) 502 + 503 + if err == nil { 504 + t.Error("GetProfile() should return error for invalid JSON") 505 + } 506 + } 507 + 508 + // TestGetProfile_EmptyDefaultHold tests profile with empty defaultHold 509 + func TestGetProfile_EmptyDefaultHold(t *testing.T) { 510 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 511 + response := `{ 512 + "uri": "at://did:plc:test123/io.atcr.sailor.profile/self", 513 + "value": { 514 + "$type": "io.atcr.sailor.profile", 515 + "defaultHold": "", 516 + "createdAt": "2025-01-01T00:00:00Z", 517 + "updatedAt": "2025-01-01T00:00:00Z" 518 + } 519 + }` 520 + w.WriteHeader(http.StatusOK) 521 + w.Write([]byte(response)) 522 + })) 523 + defer server.Close() 524 + 525 + client := NewClient(server.URL, "did:plc:test123", "test-token") 526 + profile, err := GetProfile(context.Background(), client) 527 + 528 + if err != nil { 529 + t.Fatalf("GetProfile() error = %v", err) 530 + } 531 + 532 + if profile.DefaultHold != "" { 533 + t.Errorf("DefaultHold = %v, want empty string", profile.DefaultHold) 534 + } 535 + } 536 + 537 + // TestUpdateProfile_ServerError tests error handling in UpdateProfile 538 + func TestUpdateProfile_ServerError(t *testing.T) { 539 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 540 + w.WriteHeader(http.StatusInternalServerError) 541 + w.Write([]byte(`{"error":"InternalServerError"}`)) 542 + })) 543 + defer server.Close() 544 + 545 + client := NewClient(server.URL, "did:plc:test123", "test-token") 546 + profile := &SailorProfileRecord{ 547 + Type: SailorProfileCollection, 548 + DefaultHold: "did:web:hold01.atcr.io", 549 + CreatedAt: time.Now(), 550 + UpdatedAt: time.Now(), 551 + } 552 + 553 + err := UpdateProfile(context.Background(), client, profile) 554 + 555 + if err == nil { 556 + t.Error("UpdateProfile() should return error when server fails") 557 + } 558 + }
+645
pkg/atproto/tag_store_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + 11 + "github.com/distribution/distribution/v3" 12 + "github.com/opencontainers/go-digest" 13 + ) 14 + 15 + // TestNewTagStore tests creating a new tag store 16 + func TestNewTagStore(t *testing.T) { 17 + client := NewClient("https://pds.example.com", "did:plc:test123", "token") 18 + store := NewTagStore(client, "myapp") 19 + 20 + if store.repository != "myapp" { 21 + t.Errorf("repository = %v, want myapp", store.repository) 22 + } 23 + if store.client == nil { 24 + t.Error("client should not be nil") 25 + } 26 + } 27 + 28 + // TestTagStore_Get tests retrieving a tag 29 + func TestTagStore_Get(t *testing.T) { 30 + tests := []struct { 31 + name string 32 + tag string 33 + serverResponse string 34 + serverStatus int 35 + wantErr bool 36 + wantDigest string 37 + }{ 38 + { 39 + name: "existing tag", 40 + tag: "latest", 41 + serverResponse: `{ 42 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 43 + "cid": "bafytest", 44 + "value": { 45 + "$type": "io.atcr.tag", 46 + "repository": "myapp", 47 + "tag": "latest", 48 + "manifestDigest": "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 49 + "updatedAt": "2025-01-01T00:00:00Z" 50 + } 51 + }`, 52 + serverStatus: http.StatusOK, 53 + wantErr: false, 54 + wantDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 55 + }, 56 + { 57 + name: "tag not found", 58 + tag: "notfound", 59 + serverResponse: "", 60 + serverStatus: http.StatusNotFound, 61 + wantErr: true, 62 + }, 63 + } 64 + 65 + for _, tt := range tests { 66 + t.Run(tt.name, func(t *testing.T) { 67 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 68 + // Verify query parameters 69 + query := r.URL.Query() 70 + rkey := repositoryTagToRKey("myapp", tt.tag) 71 + if query.Get("rkey") != rkey { 72 + t.Errorf("rkey = %v, want %v", query.Get("rkey"), rkey) 73 + } 74 + if query.Get("collection") != TagCollection { 75 + t.Errorf("collection = %v, want %v", query.Get("collection"), TagCollection) 76 + } 77 + 78 + w.WriteHeader(tt.serverStatus) 79 + w.Write([]byte(tt.serverResponse)) 80 + })) 81 + defer server.Close() 82 + 83 + client := NewClient(server.URL, "did:plc:test123", "test-token") 84 + store := NewTagStore(client, "myapp") 85 + 86 + desc, err := store.Get(context.Background(), tt.tag) 87 + 88 + if (err != nil) != tt.wantErr { 89 + t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr) 90 + return 91 + } 92 + 93 + if !tt.wantErr { 94 + if desc.Digest.String() != tt.wantDigest { 95 + t.Errorf("Digest = %v, want %v", desc.Digest.String(), tt.wantDigest) 96 + } 97 + if desc.MediaType != "application/vnd.oci.image.manifest.v1+json" { 98 + t.Errorf("MediaType = %v", desc.MediaType) 99 + } 100 + } 101 + }) 102 + } 103 + } 104 + 105 + // TestTagStore_Get_InvalidDigest tests error handling for invalid digest in tag record 106 + func TestTagStore_Get_InvalidDigest(t *testing.T) { 107 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 + response := `{ 109 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 110 + "value": { 111 + "$type": "io.atcr.tag", 112 + "repository": "myapp", 113 + "tag": "latest", 114 + "manifestDigest": "invalid-digest-format" 115 + } 116 + }` 117 + w.WriteHeader(http.StatusOK) 118 + w.Write([]byte(response)) 119 + })) 120 + defer server.Close() 121 + 122 + client := NewClient(server.URL, "did:plc:test123", "test-token") 123 + store := NewTagStore(client, "myapp") 124 + 125 + _, err := store.Get(context.Background(), "latest") 126 + if err == nil { 127 + t.Error("Get() should return error for invalid digest format") 128 + } 129 + } 130 + 131 + // TestTagStore_Tag tests creating/updating a tag 132 + func TestTagStore_Tag(t *testing.T) { 133 + tests := []struct { 134 + name string 135 + tag string 136 + digest digest.Digest 137 + serverStatus int 138 + wantErr bool 139 + }{ 140 + { 141 + name: "create new tag", 142 + tag: "v1.0.0", 143 + digest: "sha256:abc123def456", 144 + serverStatus: http.StatusOK, 145 + wantErr: false, 146 + }, 147 + { 148 + name: "update existing tag", 149 + tag: "latest", 150 + digest: "sha256:newdigest789", 151 + serverStatus: http.StatusOK, 152 + wantErr: false, 153 + }, 154 + { 155 + name: "server error", 156 + tag: "failed", 157 + digest: "sha256:test", 158 + serverStatus: http.StatusInternalServerError, 159 + wantErr: true, 160 + }, 161 + } 162 + 163 + for _, tt := range tests { 164 + t.Run(tt.name, func(t *testing.T) { 165 + var sentTagRecord *TagRecord 166 + 167 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 168 + if r.Method != "POST" { 169 + t.Errorf("Method = %v, want POST", r.Method) 170 + } 171 + 172 + // Parse request body 173 + var body map[string]interface{} 174 + json.NewDecoder(r.Body).Decode(&body) 175 + 176 + // Verify rkey 177 + expectedRKey := repositoryTagToRKey("myapp", tt.tag) 178 + if body["rkey"] != expectedRKey { 179 + t.Errorf("rkey = %v, want %v", body["rkey"], expectedRKey) 180 + } 181 + 182 + // Verify collection 183 + if body["collection"] != TagCollection { 184 + t.Errorf("collection = %v, want %v", body["collection"], TagCollection) 185 + } 186 + 187 + // Parse and verify tag record 188 + recordData := body["record"].(map[string]interface{}) 189 + recordBytes, _ := json.Marshal(recordData) 190 + var tagRecord TagRecord 191 + json.Unmarshal(recordBytes, &tagRecord) 192 + sentTagRecord = &tagRecord 193 + 194 + w.WriteHeader(tt.serverStatus) 195 + if tt.serverStatus == http.StatusOK { 196 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.tag/` + expectedRKey + `","cid":"bafytest"}`)) 197 + } else { 198 + w.Write([]byte(`{"error":"ServerError"}`)) 199 + } 200 + })) 201 + defer server.Close() 202 + 203 + client := NewClient(server.URL, "did:plc:test123", "test-token") 204 + store := NewTagStore(client, "myapp") 205 + 206 + desc := distribution.Descriptor{ 207 + Digest: tt.digest, 208 + MediaType: "application/vnd.oci.image.manifest.v1+json", 209 + } 210 + 211 + err := store.Tag(context.Background(), tt.tag, desc) 212 + 213 + if (err != nil) != tt.wantErr { 214 + t.Errorf("Tag() error = %v, wantErr %v", err, tt.wantErr) 215 + return 216 + } 217 + 218 + if !tt.wantErr && sentTagRecord != nil { 219 + // Verify the tag record 220 + if sentTagRecord.Type != TagCollection { 221 + t.Errorf("Type = %v, want %v", sentTagRecord.Type, TagCollection) 222 + } 223 + if sentTagRecord.Repository != "myapp" { 224 + t.Errorf("Repository = %v, want myapp", sentTagRecord.Repository) 225 + } 226 + if sentTagRecord.Tag != tt.tag { 227 + t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag) 228 + } 229 + if sentTagRecord.ManifestDigest != tt.digest.String() { 230 + t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String()) 231 + } 232 + } 233 + }) 234 + } 235 + } 236 + 237 + // TestTagStore_Untag tests removing a tag 238 + func TestTagStore_Untag(t *testing.T) { 239 + tests := []struct { 240 + name string 241 + tag string 242 + serverStatus int 243 + wantErr bool 244 + }{ 245 + { 246 + name: "successful delete", 247 + tag: "old-tag", 248 + serverStatus: http.StatusOK, 249 + wantErr: false, 250 + }, 251 + { 252 + name: "server error", 253 + tag: "tag", 254 + serverStatus: http.StatusInternalServerError, 255 + wantErr: true, 256 + }, 257 + } 258 + 259 + for _, tt := range tests { 260 + t.Run(tt.name, func(t *testing.T) { 261 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 262 + // Verify it's a DELETE request (via deleteRecord) 263 + if r.Method != "POST" { 264 + t.Errorf("Method = %v, want POST", r.Method) 265 + } 266 + 267 + // Parse body to verify delete parameters 268 + var body map[string]interface{} 269 + json.NewDecoder(r.Body).Decode(&body) 270 + 271 + expectedRKey := repositoryTagToRKey("myapp", tt.tag) 272 + if body["rkey"] != expectedRKey { 273 + t.Errorf("rkey = %v, want %v", body["rkey"], expectedRKey) 274 + } 275 + 276 + w.WriteHeader(tt.serverStatus) 277 + if tt.serverStatus == http.StatusOK { 278 + w.Write([]byte(`{}`)) 279 + } else { 280 + w.Write([]byte(`{"error":"ServerError"}`)) 281 + } 282 + })) 283 + defer server.Close() 284 + 285 + client := NewClient(server.URL, "did:plc:test123", "test-token") 286 + store := NewTagStore(client, "myapp") 287 + 288 + err := store.Untag(context.Background(), tt.tag) 289 + 290 + if (err != nil) != tt.wantErr { 291 + t.Errorf("Untag() error = %v, wantErr %v", err, tt.wantErr) 292 + } 293 + }) 294 + } 295 + } 296 + 297 + // TestTagStore_All tests listing all tags for a repository 298 + func TestTagStore_All(t *testing.T) { 299 + tests := []struct { 300 + name string 301 + serverResponse string 302 + wantTags []string 303 + }{ 304 + { 305 + name: "multiple tags for repository", 306 + serverResponse: `{ 307 + "records": [ 308 + { 309 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 310 + "value": { 311 + "$type": "io.atcr.tag", 312 + "repository": "myapp", 313 + "tag": "latest", 314 + "manifestDigest": "sha256:abc123" 315 + } 316 + }, 317 + { 318 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", 319 + "value": { 320 + "$type": "io.atcr.tag", 321 + "repository": "myapp", 322 + "tag": "v1.0.0", 323 + "manifestDigest": "sha256:def456" 324 + } 325 + }, 326 + { 327 + "uri": "at://did:plc:test123/io.atcr.tag/apper_latest", 328 + "value": { 329 + "$type": "io.atcr.tag", 330 + "repository": "apper", 331 + "tag": "latest", 332 + "manifestDigest": "sha256:xyz789" 333 + } 334 + } 335 + ] 336 + }`, 337 + wantTags: []string{"latest", "v1.0.0"}, 338 + }, 339 + { 340 + name: "no tags", 341 + serverResponse: `{ 342 + "records": [] 343 + }`, 344 + wantTags: []string{}, 345 + }, 346 + } 347 + 348 + for _, tt := range tests { 349 + t.Run(tt.name, func(t *testing.T) { 350 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 351 + // Verify query parameters 352 + query := r.URL.Query() 353 + if query.Get("collection") != TagCollection { 354 + t.Errorf("collection = %v, want %v", query.Get("collection"), TagCollection) 355 + } 356 + if query.Get("limit") != "100" { 357 + t.Errorf("limit = %v, want 100", query.Get("limit")) 358 + } 359 + 360 + w.WriteHeader(http.StatusOK) 361 + w.Write([]byte(tt.serverResponse)) 362 + })) 363 + defer server.Close() 364 + 365 + client := NewClient(server.URL, "did:plc:test123", "test-token") 366 + store := NewTagStore(client, "myapp") 367 + 368 + tags, err := store.All(context.Background()) 369 + if err != nil { 370 + t.Fatalf("All() error = %v", err) 371 + } 372 + 373 + // Sort both slices for comparison (order doesn't matter) 374 + if len(tags) != len(tt.wantTags) { 375 + t.Errorf("len(tags) = %v, want %v", len(tags), len(tt.wantTags)) 376 + } 377 + 378 + // Check that all expected tags are present 379 + tagMap := make(map[string]bool) 380 + for _, tag := range tags { 381 + tagMap[tag] = true 382 + } 383 + 384 + for _, wantTag := range tt.wantTags { 385 + if !tagMap[wantTag] { 386 + t.Errorf("Missing expected tag: %v", wantTag) 387 + } 388 + } 389 + }) 390 + } 391 + } 392 + 393 + // TestTagStore_All_SkipsInvalidRecords tests that invalid records are skipped 394 + func TestTagStore_All_SkipsInvalidRecords(t *testing.T) { 395 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 396 + response := `{ 397 + "records": [ 398 + { 399 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 400 + "value": { 401 + "$type": "io.atcr.tag", 402 + "repository": "myapp", 403 + "tag": "latest", 404 + "manifestDigest": "sha256:abc123" 405 + } 406 + }, 407 + { 408 + "uri": "at://did:plc:test123/io.atcr.tag/invalid", 409 + "value": "invalid-json-structure" 410 + }, 411 + { 412 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", 413 + "value": { 414 + "$type": "io.atcr.tag", 415 + "repository": "myapp", 416 + "tag": "v1.0.0", 417 + "manifestDigest": "sha256:def456" 418 + } 419 + } 420 + ] 421 + }` 422 + w.WriteHeader(http.StatusOK) 423 + w.Write([]byte(response)) 424 + })) 425 + defer server.Close() 426 + 427 + client := NewClient(server.URL, "did:plc:test123", "test-token") 428 + store := NewTagStore(client, "myapp") 429 + 430 + tags, err := store.All(context.Background()) 431 + if err != nil { 432 + t.Fatalf("All() error = %v", err) 433 + } 434 + 435 + // Should return 2 valid tags (invalid record skipped) 436 + if len(tags) != 2 { 437 + t.Errorf("len(tags) = %v, want 2 (invalid record should be skipped)", len(tags)) 438 + } 439 + } 440 + 441 + // TestTagStore_Lookup tests finding tags for a specific digest 442 + func TestTagStore_Lookup(t *testing.T) { 443 + targetDigest := "sha256:abc123" 444 + 445 + tests := []struct { 446 + name string 447 + digest digest.Digest 448 + serverResponse string 449 + wantTags []string 450 + }{ 451 + { 452 + name: "multiple tags point to same digest", 453 + digest: digest.Digest(targetDigest), 454 + serverResponse: `{ 455 + "records": [ 456 + { 457 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 458 + "value": { 459 + "$type": "io.atcr.tag", 460 + "repository": "myapp", 461 + "tag": "latest", 462 + "manifestDigest": "sha256:abc123" 463 + } 464 + }, 465 + { 466 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_v1.0.0", 467 + "value": { 468 + "$type": "io.atcr.tag", 469 + "repository": "myapp", 470 + "tag": "v1.0.0", 471 + "manifestDigest": "sha256:abc123" 472 + } 473 + }, 474 + { 475 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_old", 476 + "value": { 477 + "$type": "io.atcr.tag", 478 + "repository": "myapp", 479 + "tag": "old", 480 + "manifestDigest": "sha256:differentdigest" 481 + } 482 + } 483 + ] 484 + }`, 485 + wantTags: []string{"latest", "v1.0.0"}, 486 + }, 487 + { 488 + name: "no tags for digest", 489 + digest: "sha256:notfound", 490 + serverResponse: `{ 491 + "records": [ 492 + { 493 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 494 + "value": { 495 + "$type": "io.atcr.tag", 496 + "repository": "myapp", 497 + "tag": "latest", 498 + "manifestDigest": "sha256:different" 499 + } 500 + } 501 + ] 502 + }`, 503 + wantTags: []string{}, 504 + }, 505 + } 506 + 507 + for _, tt := range tests { 508 + t.Run(tt.name, func(t *testing.T) { 509 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 510 + w.WriteHeader(http.StatusOK) 511 + w.Write([]byte(tt.serverResponse)) 512 + })) 513 + defer server.Close() 514 + 515 + client := NewClient(server.URL, "did:plc:test123", "test-token") 516 + store := NewTagStore(client, "myapp") 517 + 518 + desc := distribution.Descriptor{ 519 + Digest: tt.digest, 520 + } 521 + 522 + tags, err := store.Lookup(context.Background(), desc) 523 + if err != nil { 524 + t.Fatalf("Lookup() error = %v", err) 525 + } 526 + 527 + if len(tags) != len(tt.wantTags) { 528 + t.Errorf("len(tags) = %v, want %v", len(tags), len(tt.wantTags)) 529 + } 530 + 531 + // Check that all expected tags are present 532 + tagMap := make(map[string]bool) 533 + for _, tag := range tags { 534 + tagMap[tag] = true 535 + } 536 + 537 + for _, wantTag := range tt.wantTags { 538 + if !tagMap[wantTag] { 539 + t.Errorf("Missing expected tag: %v", wantTag) 540 + } 541 + } 542 + }) 543 + } 544 + } 545 + 546 + // TestTagStore_Lookup_FiltersByRepository tests that Lookup only returns tags for the correct repository 547 + func TestTagStore_Lookup_FiltersByRepository(t *testing.T) { 548 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 549 + // Return tags from multiple repositories with same digest 550 + response := `{ 551 + "records": [ 552 + { 553 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 554 + "value": { 555 + "$type": "io.atcr.tag", 556 + "repository": "myapp", 557 + "tag": "latest", 558 + "manifestDigest": "sha256:abc123" 559 + } 560 + }, 561 + { 562 + "uri": "at://did:plc:test123/io.atcr.tag/otherapp_latest", 563 + "value": { 564 + "$type": "io.atcr.tag", 565 + "repository": "otherapp", 566 + "tag": "latest", 567 + "manifestDigest": "sha256:abc123" 568 + } 569 + } 570 + ] 571 + }` 572 + w.WriteHeader(http.StatusOK) 573 + w.Write([]byte(response)) 574 + })) 575 + defer server.Close() 576 + 577 + client := NewClient(server.URL, "did:plc:test123", "test-token") 578 + store := NewTagStore(client, "myapp") // Looking for "myapp" tags only 579 + 580 + desc := distribution.Descriptor{ 581 + Digest: "sha256:abc123", 582 + } 583 + 584 + tags, err := store.Lookup(context.Background(), desc) 585 + if err != nil { 586 + t.Fatalf("Lookup() error = %v", err) 587 + } 588 + 589 + // Should only return "latest" from "myapp", not from "otherapp" 590 + if len(tags) != 1 { 591 + t.Errorf("len(tags) = %v, want 1 (should filter by repository)", len(tags)) 592 + } 593 + 594 + if len(tags) > 0 && tags[0] != "latest" { 595 + t.Errorf("tags[0] = %v, want latest", tags[0]) 596 + } 597 + } 598 + 599 + // TestTagStore_ListRecordsError tests error handling when ListRecords fails 600 + func TestTagStore_ListRecordsError(t *testing.T) { 601 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 602 + w.WriteHeader(http.StatusInternalServerError) 603 + w.Write([]byte(`{"error":"ServerError"}`)) 604 + })) 605 + defer server.Close() 606 + 607 + client := NewClient(server.URL, "did:plc:test123", "test-token") 608 + store := NewTagStore(client, "myapp") 609 + 610 + // Test All() 611 + _, err := store.All(context.Background()) 612 + if err == nil { 613 + t.Error("All() should return error when ListRecords fails") 614 + } 615 + 616 + // Test Lookup() 617 + desc := distribution.Descriptor{Digest: "sha256:abc123"} 618 + _, err = store.Lookup(context.Background(), desc) 619 + if err == nil { 620 + t.Error("Lookup() should return error when ListRecords fails") 621 + } 622 + } 623 + 624 + // TestTagStore_GetErrorTypes tests that Get returns correct error type 625 + func TestTagStore_GetErrorTypes(t *testing.T) { 626 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 627 + w.WriteHeader(http.StatusNotFound) 628 + })) 629 + defer server.Close() 630 + 631 + client := NewClient(server.URL, "did:plc:test123", "test-token") 632 + store := NewTagStore(client, "myapp") 633 + 634 + _, err := store.Get(context.Background(), "notfound") 635 + 636 + // Should return distribution.ErrTagUnknown 637 + if err == nil { 638 + t.Error("Get() should return error for non-existent tag") 639 + } 640 + 641 + // Check if it's the right error type 642 + if !strings.Contains(err.Error(), "unknown tag") && !strings.Contains(err.Error(), "TagUnknown") { 643 + t.Errorf("Get() should return ErrTagUnknown, got: %v", err) 644 + } 645 + }
+6
pkg/auth/oauth/client.go
··· 124 124 return []string{ 125 125 "atproto", 126 126 "transition:generic", 127 + // Image manifest types (single-arch) 127 128 "blob:application/vnd.oci.image.manifest.v1+json", 128 129 "blob:application/vnd.docker.distribution.manifest.v2+json", 130 + // Manifest list/index types (multi-arch) 131 + "blob:application/vnd.oci.image.index.v1+json", 132 + "blob:application/vnd.docker.distribution.manifest.list.v2+json", 133 + // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 134 + "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 129 135 fmt.Sprintf("rpc:com.atproto.repo.getRecord?aud=%s#atcr_hold", did), 130 136 fmt.Sprintf("repo:%s", atproto.ManifestCollection), 131 137 fmt.Sprintf("repo:%s", atproto.TagCollection),
+9 -9
pkg/hold/config_test.go
··· 36 36 37 37 func TestLoadConfigFromEnv_Success(t *testing.T) { 38 38 cleanup := setupEnv(t, map[string]string{ 39 - "HOLD_PUBLIC_URL": "https://hold.example.com", 40 - "HOLD_SERVER_ADDR": ":9000", 41 - "HOLD_PUBLIC": "true", 42 - "TEST_MODE": "true", 43 - "HOLD_OWNER": "did:plc:owner123", 39 + "HOLD_PUBLIC_URL": "https://hold.example.com", 40 + "HOLD_SERVER_ADDR": ":9000", 41 + "HOLD_PUBLIC": "true", 42 + "TEST_MODE": "true", 43 + "HOLD_OWNER": "did:plc:owner123", 44 44 "HOLD_ALLOW_ALL_CREW": "true", 45 - "STORAGE_DRIVER": "filesystem", 46 - "STORAGE_ROOT_DIR": "/tmp/test-storage", 47 - "HOLD_DATABASE_DIR": "/tmp/test-db", 48 - "HOLD_KEY_PATH": "/tmp/test-key.pem", 45 + "STORAGE_DRIVER": "filesystem", 46 + "STORAGE_ROOT_DIR": "/tmp/test-storage", 47 + "HOLD_DATABASE_DIR": "/tmp/test-db", 48 + "HOLD_KEY_PATH": "/tmp/test-key.pem", 49 49 }) 50 50 defer cleanup() 51 51
+2 -1
pkg/hold/oci/multipart.go
··· 11 11 "sync" 12 12 "time" 13 13 14 + "atcr.io/pkg/atproto" 14 15 "github.com/aws/aws-sdk-go/service/s3" 15 16 "github.com/google/uuid" 16 17 ) ··· 292 293 293 294 // Buffered mode: return XRPC endpoint with headers 294 295 return &PartUploadInfo{ 295 - URL: fmt.Sprintf("%s/xrpc/io.atcr.hold.uploadPart", h.pds.PublicURL), 296 + URL: fmt.Sprintf("%s%s", h.pds.PublicURL, atproto.HoldUploadPart), 296 297 Method: "PUT", 297 298 Headers: map[string]string{ 298 299 "X-Upload-Id": uploadID,
+6 -6
pkg/hold/oci/xrpc.go
··· 6 6 "net/http" 7 7 "strconv" 8 8 9 + "atcr.io/pkg/atproto" 9 10 "atcr.io/pkg/hold/pds" 10 - 11 11 "atcr.io/pkg/s3" 12 12 storagedriver "github.com/distribution/distribution/v3/registry/storage/driver" 13 13 "github.com/go-chi/chi/v5" ··· 41 41 r.Group(func(r chi.Router) { 42 42 r.Use(h.requireBlobWriteAccess) 43 43 44 - r.Post("/xrpc/io.atcr.hold.initiateUpload", h.HandleInitiateUpload) 45 - r.Post("/xrpc/io.atcr.hold.getPartUploadUrl", h.HandleGetPartUploadUrl) 46 - r.Put("/xrpc/io.atcr.hold.uploadPart", h.HandleUploadPart) 47 - r.Post("/xrpc/io.atcr.hold.completeUpload", h.HandleCompleteUpload) 48 - r.Post("/xrpc/io.atcr.hold.abortUpload", h.HandleAbortUpload) 44 + r.Post(atproto.HoldInitiateUpload, h.HandleInitiateUpload) 45 + r.Post(atproto.HoldGetPartUploadUrl, h.HandleGetPartUploadUrl) 46 + r.Put(atproto.HoldUploadPart, h.HandleUploadPart) 47 + r.Post(atproto.HoldCompleteUpload, h.HandleCompleteUpload) 48 + r.Post(atproto.HoldAbortUpload, h.HandleAbortUpload) 49 49 }) 50 50 } 51 51