big scary refactor. sync enable_bluesky_posts with captain record. implement oauth logout handler. implement crew assignment to hold. this caused a lot of circular dependencies and needed to move functions around in order to fix

This commit is contained in:
Evan Jarrett
2025-10-24 23:51:32 -05:00
parent 0c4d1cae8f
commit f75d9ceafb
33 changed files with 852 additions and 462 deletions
+10 -10
View File
@@ -467,19 +467,19 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
return err
}
// t.EnableManifestPosts (bool) (bool)
if len("enableManifestPosts") > 8192 {
return xerrors.Errorf("Value in field \"enableManifestPosts\" was too long")
// t.EnableBlueskyPosts (bool) (bool)
if len("enableBlueskyPosts") > 8192 {
return xerrors.Errorf("Value in field \"enableBlueskyPosts\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableManifestPosts"))); err != nil {
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("enableBlueskyPosts"))); err != nil {
return err
}
if _, err := cw.WriteString(string("enableManifestPosts")); err != nil {
if _, err := cw.WriteString(string("enableBlueskyPosts")); err != nil {
return err
}
if err := cbg.WriteBool(w, t.EnableManifestPosts); err != nil {
if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil {
return err
}
return nil
@@ -617,8 +617,8 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
// t.EnableManifestPosts (bool) (bool)
case "enableManifestPosts":
// t.EnableBlueskyPosts (bool) (bool)
case "enableBlueskyPosts":
maj, extra, err = cr.ReadHeader()
if err != nil {
@@ -629,9 +629,9 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
}
switch extra {
case 20:
t.EnableManifestPosts = false
t.EnableBlueskyPosts = false
case 21:
t.EnableManifestPosts = true
t.EnableBlueskyPosts = true
default:
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
}
+4
View File
@@ -666,3 +666,7 @@ func (c *Client) FetchDIDDocument(ctx context.Context, didDocURL string) (*DIDDo
func (c *Client) DID() string {
return c.did
}
func (c *Client) PDSEndpoint() string {
return c.pdsEndpoint
}
+9 -9
View File
@@ -434,7 +434,7 @@ func ResolveHoldDIDFromURL(holdURL string) string {
}
// isDID checks if a string is a DID (starts with "did:")
func isDID(s string) bool {
func IsDID(s string) bool {
return len(s) > 4 && s[:4] == "did:"
}
@@ -536,14 +536,14 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
// Stored in the hold's embedded PDS to identify the hold owner and settings
// Uses CBOR encoding for efficient storage in hold's carstore
type CaptainRecord struct {
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableManifestPosts bool `json:"enableManifestPosts" cborgen:"enableManifestPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
Type string `json:"$type" cborgen:"$type"`
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
Public bool `json:"public" cborgen:"public"` // Public read access
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
}
// CrewRecord represents a crew member in the hold
+2 -2
View File
@@ -824,9 +824,9 @@ func TestIsDID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDID(tt.s)
got := IsDID(tt.s)
if got != tt.want {
t.Errorf("isDID() = %v, want %v", got, tt.want)
t.Errorf("IsDID() = %v, want %v", got, tt.want)
}
})
}
-122
View File
@@ -1,122 +0,0 @@
package atproto
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)
// ProfileRKey is always "self" per lexicon
const ProfileRKey = "self"
// Global map to track in-flight profile migrations (DID -> true)
// Used to prevent duplicate migration goroutines
var migrationLocks sync.Map
// EnsureProfile checks if a user's profile exists and creates it if needed
// This should be called during authentication (OAuth exchange or token service)
// If defaultHoldDID is provided, creates profile with that default (or empty if not provided)
// Expected format: "did:web:hold01.atcr.io"
// Normalizes URLs to DIDs for consistency (for backward compatibility)
func EnsureProfile(ctx context.Context, client *Client, defaultHoldDID string) error {
// Check if profile already exists
profile, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err == nil && profile != nil {
// Profile exists, nothing to do
return nil
}
// Normalize to DID if it's a URL (or pass through if already a DID)
// This ensures we store DIDs consistently in new profiles
normalizedDID := ""
if defaultHoldDID != "" {
normalizedDID = ResolveHoldDIDFromURL(defaultHoldDID)
}
// Profile doesn't exist - create it
newProfile := NewSailorProfileRecord(normalizedDID)
_, err = client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, newProfile)
if err != nil {
return fmt.Errorf("failed to create sailor profile: %w", err)
}
fmt.Printf("DEBUG [profile]: Created sailor profile with defaultHold=%s\n", normalizedDID)
return nil
}
// GetProfile retrieves the user's profile from their PDS
// Returns nil if profile doesn't exist
// Automatically migrates old URL-based defaultHold values to DIDs
func GetProfile(ctx context.Context, client *Client) (*SailorProfileRecord, error) {
record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
if err != nil {
// Check if it's a 404 (profile doesn't exist)
if errors.Is(err, ErrRecordNotFound) {
return nil, nil
}
return nil, fmt.Errorf("failed to get profile: %w", err)
}
// Parse the profile record
var profile SailorProfileRecord
if err := json.Unmarshal(record.Value, &profile); err != nil {
return nil, fmt.Errorf("failed to parse profile: %w", err)
}
// Migrate old URL-based defaultHold to DID format
// This ensures backward compatibility with profiles created before DID migration
if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
// Convert URL to DID transparently
migratedDID := ResolveHoldDIDFromURL(profile.DefaultHold)
profile.DefaultHold = migratedDID
// Persist the migration to PDS in a background goroutine
// Use a lock to ensure only one goroutine migrates this DID
did := client.did
if _, loaded := migrationLocks.LoadOrStore(did, true); !loaded {
// We got the lock - launch goroutine to persist the migration
go func() {
// Clean up lock when done (after a short delay to batch requests)
defer func() {
time.Sleep(1 * time.Second)
migrationLocks.Delete(did)
}()
// Create a new context with timeout for the background operation
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Update the profile on the PDS
profile.UpdatedAt = time.Now()
if err := UpdateProfile(ctx, client, &profile); err != nil {
fmt.Printf("WARNING [profile]: Failed to persist URL-to-DID migration for %s: %v\n", did, err)
} else {
fmt.Printf("DEBUG [profile]: Persisted defaultHold migration to DID: %s (for DID: %s)\n", migratedDID, did)
}
}()
}
}
return &profile, nil
}
// UpdateProfile updates the user's profile
// Normalizes defaultHold to DID format before saving
func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
// Normalize defaultHold to DID if it's a URL
// This ensures we always store DIDs, even if user provides a URL
if profile.DefaultHold != "" && !isDID(profile.DefaultHold) {
profile.DefaultHold = ResolveHoldDIDFromURL(profile.DefaultHold)
fmt.Printf("DEBUG [profile]: Normalized defaultHold to DID: %s\n", profile.DefaultHold)
}
_, err := client.PutRecord(ctx, SailorProfileCollection, ProfileRKey, profile)
if err != nil {
return fmt.Errorf("failed to update profile: %w", err)
}
return nil
}
-558
View File
@@ -1,558 +0,0 @@
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]any
json.NewDecoder(r.Body).Decode(&body)
// Verify profile data
recordData := body["record"].(map[string]any)
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]any
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]any)
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]any
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]any
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]any)
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")
}
}
+33
View File
@@ -0,0 +1,33 @@
package atproto
import "strings"
// ResolveHoldURL converts a hold identifier (DID or URL) to an HTTP/HTTPS URL
// Handles both formats for backward compatibility:
// - DID format: did:web:hold01.atcr.io → https://hold01.atcr.io
// - DID with port: did:web:172.28.0.3:8080 → http://172.28.0.3:8080
// - URL format: https://hold.example.com → https://hold.example.com (passthrough)
func ResolveHoldURL(holdIdentifier string) string {
// If it's already a URL (has scheme), return as-is
if strings.HasPrefix(holdIdentifier, "http://") || strings.HasPrefix(holdIdentifier, "https://") {
return holdIdentifier
}
// If it's a DID, convert to URL
if after, ok := strings.CutPrefix(holdIdentifier, "did:web:"); ok {
hostname := after
// Use HTTP for localhost/IP addresses with ports, HTTPS for domains
if strings.Contains(hostname, ":") ||
strings.Contains(hostname, "127.0.0.1") ||
strings.Contains(hostname, "localhost") ||
// Check if it's an IP address (contains only digits and dots in first part)
(len(hostname) > 0 && hostname[0] >= '0' && hostname[0] <= '9') {
return "http://" + hostname
}
return "https://" + hostname
}
// Fallback: assume it's a hostname and use HTTPS
return "https://" + holdIdentifier
}