A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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