remove unused filestore. replace it with memstore for tests

This commit is contained in:
Evan Jarrett
2025-12-29 16:51:08 -06:00
parent c1f2ae0f7a
commit 8956568ed2
9 changed files with 118 additions and 1000 deletions
+84
View File
@@ -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 |
+3 -3
View File
@@ -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,
+10 -10
View File
@@ -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
-1
View File
@@ -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 {
+7 -30
View File
@@ -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")
+1 -5
View File
@@ -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.)
+13 -84
View File
@@ -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")
-236
View File
@@ -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)
}
-631
View File
@@ -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")
}