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