From 8956568ed2a9fd8b46e61c3163e5fa0116f2f250 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 29 Dec 2025 16:50:06 -0600 Subject: [PATCH] remove unused filestore. replace it with memstore for tests --- docs/HOLD_XRPC_ENDPOINTS.md | 84 ++++ pkg/appview/middleware/registry.go | 6 +- pkg/appview/storage/context.go | 20 +- pkg/atproto/lexicon.go | 1 - pkg/auth/oauth/client_test.go | 37 +- pkg/auth/oauth/interactive.go | 6 +- pkg/auth/oauth/server_test.go | 97 +---- pkg/auth/oauth/store.go | 236 ----------- pkg/auth/oauth/store_test.go | 631 ----------------------------- 9 files changed, 118 insertions(+), 1000 deletions(-) create mode 100644 docs/HOLD_XRPC_ENDPOINTS.md delete mode 100644 pkg/auth/oauth/store.go delete mode 100644 pkg/auth/oauth/store_test.go diff --git a/docs/HOLD_XRPC_ENDPOINTS.md b/docs/HOLD_XRPC_ENDPOINTS.md new file mode 100644 index 0000000..27120b0 --- /dev/null +++ b/docs/HOLD_XRPC_ENDPOINTS.md @@ -0,0 +1,84 @@ +# Hold Service XRPC Endpoints + +This document lists all XRPC endpoints implemented in the Hold service (`pkg/hold/`). + +## PDS Endpoints (`pkg/hold/pds/xrpc.go`) + +### Public (No Auth Required) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/_health` | GET | Health check | +| `/xrpc/com.atproto.server.describeServer` | GET | Server metadata | +| `/xrpc/com.atproto.repo.describeRepo` | GET | Repository information | +| `/xrpc/com.atproto.repo.getRecord` | GET | Retrieve a single record | +| `/xrpc/com.atproto.repo.listRecords` | GET | List records in a collection (paginated) | +| `/xrpc/com.atproto.sync.listRepos` | GET | List all repositories | +| `/xrpc/com.atproto.sync.getRecord` | GET | Get record as CAR file | +| `/xrpc/com.atproto.sync.getRepo` | GET | Full repository as CAR file | +| `/xrpc/com.atproto.sync.getRepoStatus` | GET | Repository hosting status | +| `/xrpc/com.atproto.sync.subscribeRepos` | GET | WebSocket firehose | +| `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID | +| `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile | +| `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles | +| `/.well-known/did.json` | GET | DID document | +| `/.well-known/atproto-did` | GET | DID for handle resolution | + +### Conditional Auth (based on captain.public) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/com.atproto.sync.getBlob` | GET/HEAD | Get blob (routes OCI vs ATProto) | + +### Owner/Crew Admin Required + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record | +| `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob | + +### DPoP Auth Required + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership | + +--- + +## OCI Multipart Upload Endpoints (`pkg/hold/oci/xrpc.go`) + +All require `blob:write` permission via service token: + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/xrpc/io.atcr.hold.initiateUpload` | POST | Start multipart upload | +| `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | Get presigned URL for part | +| `/xrpc/io.atcr.hold.uploadPart` | PUT | Direct buffered part upload | +| `/xrpc/io.atcr.hold.completeUpload` | POST | Finalize multipart upload | +| `/xrpc/io.atcr.hold.abortUpload` | POST | Cancel multipart upload | +| `/xrpc/io.atcr.hold.notifyManifest` | POST | Notify manifest push (creates layer records + optional Bluesky post) | + +--- + +## Standard ATProto Endpoints (excluding io.atcr.hold.*) + +| Endpoint | +|----------| +| /xrpc/_health | +| /xrpc/com.atproto.server.describeServer | +| /xrpc/com.atproto.repo.describeRepo | +| /xrpc/com.atproto.repo.getRecord | +| /xrpc/com.atproto.repo.listRecords | +| /xrpc/com.atproto.repo.deleteRecord | +| /xrpc/com.atproto.repo.uploadBlob | +| /xrpc/com.atproto.sync.listRepos | +| /xrpc/com.atproto.sync.getRecord | +| /xrpc/com.atproto.sync.getRepo | +| /xrpc/com.atproto.sync.getRepoStatus | +| /xrpc/com.atproto.sync.getBlob | +| /xrpc/com.atproto.sync.subscribeRepos | +| /xrpc/com.atproto.identity.resolveHandle | +| /xrpc/app.bsky.actor.getProfile | +| /xrpc/app.bsky.actor.getProfiles | +| /.well-known/did.json | +| /.well-known/atproto-did | diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 8ddb08b..070edf1 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -460,9 +460,9 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name Repository: repositoryName, ServiceToken: serviceToken, // Cached service token from puller's PDS ATProtoClient: atprotoClient, - AuthMethod: authMethod, // Auth method from JWT token - PullerDID: pullerDID, // Authenticated user making the request - PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh + AuthMethod: authMethod, // Auth method from JWT token + PullerDID: pullerDID, // Authenticated user making the request + PullerPDSEndpoint: pullerPDSEndpoint, // Puller's PDS for service token refresh Database: nr.database, Authorizer: nr.authorizer, Refresher: nr.refresher, diff --git a/pkg/appview/storage/context.go b/pkg/appview/storage/context.go index e30d504..dda615e 100644 --- a/pkg/appview/storage/context.go +++ b/pkg/appview/storage/context.go @@ -20,16 +20,16 @@ type RegistryContext struct { // Per-request identity and routing information // Owner = the user whose repository is being accessed // Puller = the authenticated user making the request (from JWT Subject) - DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123") - Handle string // Owner's handle (e.g., "alice.bsky.social") - HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io") - PDSEndpoint string // Owner's PDS endpoint URL - Repository string // Image repository name (e.g., "debian") - ServiceToken string // Service token for hold authentication (from puller's PDS) - ATProtoClient *atproto.Client // Authenticated ATProto client for the owner - AuthMethod string // Auth method used ("oauth" or "app_password") - PullerDID string // Puller's DID - who is making the request (from JWT Subject) - PullerPDSEndpoint string // Puller's PDS endpoint URL + DID string // Owner's DID - whose repo is being accessed (e.g., "did:plc:abc123") + Handle string // Owner's handle (e.g., "alice.bsky.social") + HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io") + PDSEndpoint string // Owner's PDS endpoint URL + Repository string // Image repository name (e.g., "debian") + ServiceToken string // Service token for hold authentication (from puller's PDS) + ATProtoClient *atproto.Client // Authenticated ATProto client for the owner + AuthMethod string // Auth method used ("oauth" or "app_password") + PullerDID string // Puller's DID - who is making the request (from JWT Subject) + PullerPDSEndpoint string // Puller's PDS endpoint URL // Shared services (same for all requests) Database DatabaseMetrics // Metrics tracking database diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 66f5fcf..5cfa042 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -310,7 +310,6 @@ type HoldRecord struct { CreatedAt time.Time `json:"createdAt"` } - // SailorProfileRecord represents a user's profile with registry preferences // Stored in the user's PDS to configure default hold and other settings type SailorProfileRecord struct { diff --git a/pkg/auth/oauth/client_test.go b/pkg/auth/oauth/client_test.go index 9899782..e860d4c 100644 --- a/pkg/auth/oauth/client_test.go +++ b/pkg/auth/oauth/client_test.go @@ -1,18 +1,13 @@ package oauth import ( + "github.com/bluesky-social/indigo/atproto/auth/oauth" "testing" ) func TestNewClientApp(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - keyPath := tmpDir + "/oauth-key.bin" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + keyPath := t.TempDir() + "/oauth-key.bin" + store := oauth.NewMemStore() baseURL := "http://localhost:5000" scopes := GetDefaultScopes("*") @@ -32,14 +27,8 @@ func TestNewClientApp(t *testing.T) { } func TestNewClientAppWithCustomScopes(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - keyPath := tmpDir + "/oauth-key.bin" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + keyPath := t.TempDir() + "/oauth-key.bin" + store := oauth.NewMemStore() baseURL := "http://localhost:5000" scopes := []string{"atproto", "custom:scope"} @@ -128,13 +117,7 @@ func TestScopesMatch(t *testing.T) { // ---------------------------------------------------------------------------- func TestNewRefresher(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -153,13 +136,7 @@ func TestNewRefresher(t *testing.T) { } func TestRefresher_SetUISessionStore(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") diff --git a/pkg/auth/oauth/interactive.go b/pkg/auth/oauth/interactive.go index 79c1dfa..e368be9 100644 --- a/pkg/auth/oauth/interactive.go +++ b/pkg/auth/oauth/interactive.go @@ -26,11 +26,7 @@ func InteractiveFlowWithCallback( registerCallback func(handler http.HandlerFunc) error, displayAuthURL func(string) error, ) (*InteractiveResult, error) { - // Create temporary file store for this flow - store, err := NewFileStore("/tmp/atcr-oauth-temp.json") - if err != nil { - return nil, fmt.Errorf("failed to create OAuth store: %w", err) - } + store := oauth.NewMemStore() // Create OAuth client app with custom scopes (or defaults if nil) // Interactive flows are typically for production use (credential helper, etc.) diff --git a/pkg/auth/oauth/server_test.go b/pkg/auth/oauth/server_test.go index 54984b3..4b3aecf 100644 --- a/pkg/auth/oauth/server_test.go +++ b/pkg/auth/oauth/server_test.go @@ -2,6 +2,7 @@ package oauth import ( "context" + "github.com/bluesky-social/indigo/atproto/auth/oauth" "net/http" "net/http/httptest" "strings" @@ -11,13 +12,7 @@ import ( func TestNewServer(t *testing.T) { // Create a basic OAuth app for testing - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -36,13 +31,7 @@ func TestNewServer(t *testing.T) { } func TestServer_SetRefresher(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -60,13 +49,7 @@ func TestServer_SetRefresher(t *testing.T) { } func TestServer_SetPostAuthCallback(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -87,13 +70,7 @@ func TestServer_SetPostAuthCallback(t *testing.T) { } func TestServer_SetUISessionStore(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -151,13 +128,7 @@ func (m *mockRefresher) InvalidateSession(did string) { // ServeAuthorize tests func TestServer_ServeAuthorize_MissingHandle(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -179,13 +150,7 @@ func TestServer_ServeAuthorize_MissingHandle(t *testing.T) { } func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -209,13 +174,7 @@ func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) { // ServeCallback tests func TestServer_ServeCallback_InvalidMethod(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -237,13 +196,7 @@ func TestServer_ServeCallback_InvalidMethod(t *testing.T) { } func TestServer_ServeCallback_OAuthError(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -270,13 +223,7 @@ func TestServer_ServeCallback_OAuthError(t *testing.T) { } func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -315,13 +262,7 @@ func TestServer_ServeCallback_UIFlow_SessionCreationLogic(t *testing.T) { }, } - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -345,13 +286,7 @@ func TestServer_ServeCallback_UIFlow_SessionCreationLogic(t *testing.T) { } func TestServer_RenderError(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") @@ -380,13 +315,7 @@ func TestServer_RenderError(t *testing.T) { } func TestServer_RenderRedirectToSettings(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } + store := oauth.NewMemStore() scopes := GetDefaultScopes("*") clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry") diff --git a/pkg/auth/oauth/store.go b/pkg/auth/oauth/store.go deleted file mode 100644 index de0ea22..0000000 --- a/pkg/auth/oauth/store.go +++ /dev/null @@ -1,236 +0,0 @@ -package oauth - -import ( - "context" - "encoding/json" - "fmt" - "maps" - "os" - "path/filepath" - "sync" - "time" - - "github.com/bluesky-social/indigo/atproto/auth/oauth" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -// FileStore implements oauth.ClientAuthStore with file-based persistence -type FileStore struct { - path string - sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID" - requests map[string]*oauth.AuthRequestData // Key: state - mu sync.RWMutex -} - -// FileStoreData represents the JSON structure stored on disk -type FileStoreData struct { - Sessions map[string]*oauth.ClientSessionData `json:"sessions"` - Requests map[string]*oauth.AuthRequestData `json:"requests"` -} - -// NewFileStore creates a new file-based OAuth store -func NewFileStore(path string) (*FileStore, error) { - store := &FileStore{ - path: path, - sessions: make(map[string]*oauth.ClientSessionData), - requests: make(map[string]*oauth.AuthRequestData), - } - - // Load existing data if file exists - if err := store.load(); err != nil { - if !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to load store: %w", err) - } - // File doesn't exist yet, that's ok - } - - return store, nil -} - -// GetDefaultStorePath returns the default storage path for OAuth data -func GetDefaultStorePath() (string, error) { - // For AppView: /var/lib/atcr/oauth-sessions.json - // For CLI tools: ~/.atcr/oauth-sessions.json - - // Check if running as a service (has write access to /var/lib) - servicePath := "/var/lib/atcr/oauth-sessions.json" - if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil { - // Can write to /var/lib, use service path - return servicePath, nil - } - - // Fall back to user home directory - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - - atcrDir := filepath.Join(homeDir, ".atcr") - if err := os.MkdirAll(atcrDir, 0700); err != nil { - return "", fmt.Errorf("failed to create .atcr directory: %w", err) - } - - return filepath.Join(atcrDir, "oauth-sessions.json"), nil -} - -// GetSession retrieves a session by DID and session ID -func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - key := makeSessionKey(did.String(), sessionID) - session, ok := s.sessions[key] - if !ok { - return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) - } - - return session, nil -} - -// SaveSession saves or updates a session (upsert) -func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { - s.mu.Lock() - defer s.mu.Unlock() - - key := makeSessionKey(sess.AccountDID.String(), sess.SessionID) - s.sessions[key] = &sess - - return s.save() -} - -// DeleteSession removes a session -func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - key := makeSessionKey(did.String(), sessionID) - delete(s.sessions, key) - - return s.save() -} - -// GetAuthRequestInfo retrieves authentication request data by state -func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - request, ok := s.requests[state] - if !ok { - return nil, fmt.Errorf("auth request not found: %s", state) - } - - return request, nil -} - -// SaveAuthRequestInfo saves authentication request data -func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.requests[info.State] = &info - - return s.save() -} - -// DeleteAuthRequestInfo removes authentication request data -func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.requests, state) - - return s.save() -} - -// CleanupExpired removes expired sessions and auth requests -// Should be called periodically (e.g., every hour) -func (s *FileStore) CleanupExpired() error { - s.mu.Lock() - defer s.mu.Unlock() - - now := time.Now() - modified := false - - // Clean up auth requests older than 10 minutes - // (OAuth flows should complete quickly) - for state := range s.requests { - // Note: AuthRequestData doesn't have a timestamp in indigo's implementation - // For now, we'll rely on the OAuth server's cleanup routine - // or we could extend AuthRequestData with metadata - _ = state // Placeholder for future expiration logic - } - - // Sessions don't have expiry in the data structure - // Cleanup would need to be token-based (check token expiry) - // For now, manual cleanup via DeleteSession - _ = now - - if modified { - return s.save() - } - - return nil -} - -// ListSessions returns all stored sessions for debugging/management -func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData { - s.mu.RLock() - defer s.mu.RUnlock() - - // Return a copy to prevent external modification - result := make(map[string]*oauth.ClientSessionData) - maps.Copy(result, s.sessions) - return result -} - -// load reads data from disk -func (s *FileStore) load() error { - data, err := os.ReadFile(s.path) - if err != nil { - return err - } - - var storeData FileStoreData - if err := json.Unmarshal(data, &storeData); err != nil { - return fmt.Errorf("failed to parse store: %w", err) - } - - if storeData.Sessions != nil { - s.sessions = storeData.Sessions - } - if storeData.Requests != nil { - s.requests = storeData.Requests - } - - return nil -} - -// save writes data to disk -func (s *FileStore) save() error { - storeData := FileStoreData{ - Sessions: s.sessions, - Requests: s.requests, - } - - data, err := json.MarshalIndent(storeData, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal store: %w", err) - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - // Write with restrictive permissions - if err := os.WriteFile(s.path, data, 0600); err != nil { - return fmt.Errorf("failed to write store: %w", err) - } - - return nil -} - -// makeSessionKey creates a composite key for session storage -func makeSessionKey(did, sessionID string) string { - return fmt.Sprintf("%s:%s", did, sessionID) -} diff --git a/pkg/auth/oauth/store_test.go b/pkg/auth/oauth/store_test.go deleted file mode 100644 index 3f2088f..0000000 --- a/pkg/auth/oauth/store_test.go +++ /dev/null @@ -1,631 +0,0 @@ -package oauth - -import ( - "context" - "encoding/json" - "os" - "testing" - "time" - - "github.com/bluesky-social/indigo/atproto/auth/oauth" - "github.com/bluesky-social/indigo/atproto/syntax" -) - -func TestNewFileStore(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - if store == nil { - t.Fatal("Expected non-nil store") - } - - if store.path != storePath { - t.Errorf("Expected path %q, got %q", storePath, store.path) - } - - if store.sessions == nil { - t.Error("Expected sessions map to be initialized") - } - - if store.requests == nil { - t.Error("Expected requests map to be initialized") - } -} - -func TestFileStore_LoadNonExistent(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/nonexistent.json" - - // Should succeed even if file doesn't exist - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() should succeed with non-existent file, got error: %v", err) - } - - if store == nil { - t.Fatal("Expected non-nil store") - } -} - -func TestFileStore_LoadCorruptedFile(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/corrupted.json" - - // Create corrupted JSON file - if err := os.WriteFile(storePath, []byte("invalid json {{{"), 0600); err != nil { - t.Fatalf("Failed to create corrupted file: %v", err) - } - - // Should fail to load corrupted file - _, err := NewFileStore(storePath) - if err == nil { - t.Error("Expected error when loading corrupted file") - } -} - -func TestFileStore_GetSession_NotFound(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:test123") - sessionID := "session123" - - // Should return error for non-existent session - session, err := store.GetSession(ctx, did, sessionID) - if err == nil { - t.Error("Expected error for non-existent session") - } - if session != nil { - t.Error("Expected nil session for non-existent entry") - } -} - -func TestFileStore_SaveAndGetSession(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Create test session - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "test-session-123", - HostURL: "https://pds.example.com", - Scopes: []string{"atproto", "blob:read"}, - } - - // Save session - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // Retrieve session - retrieved, err := store.GetSession(ctx, did, "test-session-123") - if err != nil { - t.Fatalf("GetSession() error = %v", err) - } - - if retrieved == nil { - t.Fatal("Expected non-nil session") - } - - if retrieved.SessionID != sessionData.SessionID { - t.Errorf("Expected sessionID %q, got %q", sessionData.SessionID, retrieved.SessionID) - } - - if retrieved.AccountDID.String() != did.String() { - t.Errorf("Expected DID %q, got %q", did.String(), retrieved.AccountDID.String()) - } - - if retrieved.HostURL != sessionData.HostURL { - t.Errorf("Expected hostURL %q, got %q", sessionData.HostURL, retrieved.HostURL) - } -} - -func TestFileStore_UpdateSession(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Save initial session - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "test-session-123", - HostURL: "https://pds.example.com", - Scopes: []string{"atproto"}, - } - - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // Update session with new scopes - sessionData.Scopes = []string{"atproto", "blob:read", "blob:write"} - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() (update) error = %v", err) - } - - // Retrieve updated session - retrieved, err := store.GetSession(ctx, did, "test-session-123") - if err != nil { - t.Fatalf("GetSession() error = %v", err) - } - - if len(retrieved.Scopes) != 3 { - t.Errorf("Expected 3 scopes, got %d", len(retrieved.Scopes)) - } -} - -func TestFileStore_DeleteSession(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Save session - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "test-session-123", - HostURL: "https://pds.example.com", - } - - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // Verify it exists - if _, err := store.GetSession(ctx, did, "test-session-123"); err != nil { - t.Fatalf("GetSession() should succeed before delete, got error: %v", err) - } - - // Delete session - if err := store.DeleteSession(ctx, did, "test-session-123"); err != nil { - t.Fatalf("DeleteSession() error = %v", err) - } - - // Verify it's gone - _, err = store.GetSession(ctx, did, "test-session-123") - if err == nil { - t.Error("Expected error after deleting session") - } -} - -func TestFileStore_DeleteNonExistentSession(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Delete non-existent session should not error - if err := store.DeleteSession(ctx, did, "nonexistent"); err != nil { - t.Errorf("DeleteSession() on non-existent session should not error, got: %v", err) - } -} - -func TestFileStore_SaveAndGetAuthRequestInfo(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - - // Create test auth request - did, _ := syntax.ParseDID("did:plc:alice123") - authRequest := oauth.AuthRequestData{ - State: "test-state-123", - AuthServerURL: "https://pds.example.com", - AccountDID: &did, - Scopes: []string{"atproto", "blob:read"}, - RequestURI: "urn:ietf:params:oauth:request_uri:test123", - AuthServerTokenEndpoint: "https://pds.example.com/oauth/token", - } - - // Save auth request - if err := store.SaveAuthRequestInfo(ctx, authRequest); err != nil { - t.Fatalf("SaveAuthRequestInfo() error = %v", err) - } - - // Retrieve auth request - retrieved, err := store.GetAuthRequestInfo(ctx, "test-state-123") - if err != nil { - t.Fatalf("GetAuthRequestInfo() error = %v", err) - } - - if retrieved == nil { - t.Fatal("Expected non-nil auth request") - } - - if retrieved.State != authRequest.State { - t.Errorf("Expected state %q, got %q", authRequest.State, retrieved.State) - } - - if retrieved.AuthServerURL != authRequest.AuthServerURL { - t.Errorf("Expected authServerURL %q, got %q", authRequest.AuthServerURL, retrieved.AuthServerURL) - } -} - -func TestFileStore_GetAuthRequestInfo_NotFound(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - - // Should return error for non-existent request - _, err = store.GetAuthRequestInfo(ctx, "nonexistent-state") - if err == nil { - t.Error("Expected error for non-existent auth request") - } -} - -func TestFileStore_DeleteAuthRequestInfo(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - - // Save auth request - authRequest := oauth.AuthRequestData{ - State: "test-state-123", - AuthServerURL: "https://pds.example.com", - } - - if err := store.SaveAuthRequestInfo(ctx, authRequest); err != nil { - t.Fatalf("SaveAuthRequestInfo() error = %v", err) - } - - // Verify it exists - if _, err := store.GetAuthRequestInfo(ctx, "test-state-123"); err != nil { - t.Fatalf("GetAuthRequestInfo() should succeed before delete, got error: %v", err) - } - - // Delete auth request - if err := store.DeleteAuthRequestInfo(ctx, "test-state-123"); err != nil { - t.Fatalf("DeleteAuthRequestInfo() error = %v", err) - } - - // Verify it's gone - _, err = store.GetAuthRequestInfo(ctx, "test-state-123") - if err == nil { - t.Error("Expected error after deleting auth request") - } -} - -func TestFileStore_ListSessions(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - - // Initially empty - sessions := store.ListSessions() - if len(sessions) != 0 { - t.Errorf("Expected 0 sessions, got %d", len(sessions)) - } - - // Add multiple sessions - did1, _ := syntax.ParseDID("did:plc:alice123") - did2, _ := syntax.ParseDID("did:plc:bob456") - - session1 := oauth.ClientSessionData{ - AccountDID: did1, - SessionID: "session-1", - HostURL: "https://pds1.example.com", - } - - session2 := oauth.ClientSessionData{ - AccountDID: did2, - SessionID: "session-2", - HostURL: "https://pds2.example.com", - } - - if err := store.SaveSession(ctx, session1); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - if err := store.SaveSession(ctx, session2); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // List sessions - sessions = store.ListSessions() - if len(sessions) != 2 { - t.Errorf("Expected 2 sessions, got %d", len(sessions)) - } - - // Verify we got both sessions - key1 := makeSessionKey(did1.String(), "session-1") - key2 := makeSessionKey(did2.String(), "session-2") - - if sessions[key1] == nil { - t.Error("Expected session1 in list") - } - - if sessions[key2] == nil { - t.Error("Expected session2 in list") - } -} - -func TestFileStore_Persistence_Across_Instances(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Create first store and save data - store1, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "persistent-session", - HostURL: "https://pds.example.com", - } - - if err := store1.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - authRequest := oauth.AuthRequestData{ - State: "persistent-state", - AuthServerURL: "https://pds.example.com", - } - - if err := store1.SaveAuthRequestInfo(ctx, authRequest); err != nil { - t.Fatalf("SaveAuthRequestInfo() error = %v", err) - } - - // Create second store from same file - store2, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("Second NewFileStore() error = %v", err) - } - - // Verify session persisted - retrievedSession, err := store2.GetSession(ctx, did, "persistent-session") - if err != nil { - t.Fatalf("GetSession() from second store error = %v", err) - } - - if retrievedSession.SessionID != "persistent-session" { - t.Errorf("Expected persistent session ID, got %q", retrievedSession.SessionID) - } - - // Verify auth request persisted - retrievedAuth, err := store2.GetAuthRequestInfo(ctx, "persistent-state") - if err != nil { - t.Fatalf("GetAuthRequestInfo() from second store error = %v", err) - } - - if retrievedAuth.State != "persistent-state" { - t.Errorf("Expected persistent state, got %q", retrievedAuth.State) - } -} - -func TestFileStore_FileSecurity(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Save some data to trigger file creation - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "test-session", - HostURL: "https://pds.example.com", - } - - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // Check file permissions (should be 0600) - info, err := os.Stat(storePath) - if err != nil { - t.Fatalf("Failed to stat file: %v", err) - } - - mode := info.Mode() - if mode.Perm() != 0600 { - t.Errorf("Expected file permissions 0600, got %o", mode.Perm()) - } -} - -func TestFileStore_JSONFormat(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - did, _ := syntax.ParseDID("did:plc:alice123") - - // Save data - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "test-session", - HostURL: "https://pds.example.com", - } - - if err := store.SaveSession(ctx, sessionData); err != nil { - t.Fatalf("SaveSession() error = %v", err) - } - - // Read and verify JSON format - data, err := os.ReadFile(storePath) - if err != nil { - t.Fatalf("Failed to read file: %v", err) - } - - var storeData FileStoreData - if err := json.Unmarshal(data, &storeData); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if storeData.Sessions == nil { - t.Error("Expected sessions in JSON") - } - - if storeData.Requests == nil { - t.Error("Expected requests in JSON") - } -} - -func TestFileStore_CleanupExpired(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - // CleanupExpired should not error even with no data - if err := store.CleanupExpired(); err != nil { - t.Errorf("CleanupExpired() error = %v", err) - } - - // Note: Current implementation doesn't actually clean anything - // since AuthRequestData and ClientSessionData don't have expiry timestamps - // This test verifies the method doesn't panic -} - -func TestGetDefaultStorePath(t *testing.T) { - path, err := GetDefaultStorePath() - if err != nil { - t.Fatalf("GetDefaultStorePath() error = %v", err) - } - - if path == "" { - t.Fatal("Expected non-empty path") - } - - // Path should either be /var/lib/atcr or ~/.atcr - // We can't assert exact path since it depends on permissions - t.Logf("Default store path: %s", path) -} - -func TestMakeSessionKey(t *testing.T) { - did := "did:plc:alice123" - sessionID := "session-456" - - key := makeSessionKey(did, sessionID) - expected := "did:plc:alice123:session-456" - - if key != expected { - t.Errorf("Expected key %q, got %q", expected, key) - } -} - -func TestFileStore_ConcurrentAccess(t *testing.T) { - tmpDir := t.TempDir() - storePath := tmpDir + "/oauth-test.json" - - store, err := NewFileStore(storePath) - if err != nil { - t.Fatalf("NewFileStore() error = %v", err) - } - - ctx := context.Background() - - // Run concurrent operations - done := make(chan bool) - - // Writer goroutine - go func() { - for i := 0; i < 10; i++ { - did, _ := syntax.ParseDID("did:plc:alice123") - sessionData := oauth.ClientSessionData{ - AccountDID: did, - SessionID: "session-1", - HostURL: "https://pds.example.com", - } - store.SaveSession(ctx, sessionData) - time.Sleep(1 * time.Millisecond) - } - done <- true - }() - - // Reader goroutine - go func() { - for i := 0; i < 10; i++ { - did, _ := syntax.ParseDID("did:plc:alice123") - store.GetSession(ctx, did, "session-1") - time.Sleep(1 * time.Millisecond) - } - done <- true - }() - - // Wait for both goroutines - <-done - <-done - - // If we got here without panicking, the locking works - t.Log("Concurrent access test passed") -}