add test coverage for xrpc endpoints, match spec as close as possible

This commit is contained in:
Evan Jarrett
2025-10-16 20:42:14 -05:00
parent 963786f7cc
commit 7cf6da09f9
3 changed files with 1599 additions and 12 deletions
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"flag"
"fmt"
"log"
"net/http"
"os"
"time"
"atcr.io/pkg/auth/oauth"
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
)
func main() {
handle := flag.String("handle", "", "Your Bluesky handle (e.g., yourname.bsky.social)")
holdURL := flag.String("hold-url", "http://localhost:8080", "Hold service URL")
repo := flag.String("repo", "", "Repository DID (e.g., did:web:172.28.0.3:8080)")
collection := flag.String("collection", "io.atcr.hold.crew", "Collection to delete from")
rkey := flag.String("rkey", "", "Record key to delete")
flag.Parse()
if *handle == "" {
fmt.Println("Usage: oauth-helper --handle yourname.bsky.social [options]")
fmt.Println("\nOptions:")
flag.PrintDefaults()
os.Exit(1)
}
ctx := context.Background()
fmt.Printf("🔐 Starting OAuth flow for %s...\n\n", *handle)
// Create a simple HTTP server for the callback
mux := http.NewServeMux()
server := &http.Server{
Addr: ":8765",
Handler: mux,
}
// Channel to receive the result
resultChan := make(chan *oauth.InteractiveResult, 1)
errorChan := make(chan error, 1)
// Register callback handler
registerCallback := func(handler http.HandlerFunc) error {
mux.HandleFunc("/auth/oauth/callback", handler)
return nil
}
// Display auth URL (will open browser)
displayAuthURL := func(authURL string) error {
fmt.Printf("🌐 Opening browser for authorization...\n")
fmt.Printf(" URL: %s\n\n", authURL)
fmt.Printf(" If the browser doesn't open, visit the URL above.\n\n")
return oauth.OpenBrowser(authURL)
}
// Start server in background
go func() {
if err := server.ListenAndServe(); err != http.ErrServerClosed {
errorChan <- fmt.Errorf("server error: %w", err)
}
}()
// Give server time to start
time.Sleep(100 * time.Millisecond)
// Run interactive OAuth flow
go func() {
result, err := oauth.InteractiveFlowWithCallback(
ctx,
"http://localhost:8765",
*handle,
nil, // Use default scopes
registerCallback,
displayAuthURL,
)
if err != nil {
errorChan <- err
return
}
resultChan <- result
}()
// Wait for result
var result *oauth.InteractiveResult
select {
case result = <-resultChan:
fmt.Printf("✅ OAuth successful!\n\n")
case err := <-errorChan:
log.Fatalf("❌ OAuth failed: %v\n", err)
case <-time.After(5 * time.Minute):
log.Fatalf("❌ OAuth timed out\n")
}
// Shutdown server
server.Shutdown(ctx)
// Print session information
fmt.Printf("DID: %s\n", result.SessionData.AccountDID)
fmt.Printf("Access Token: %s\n", result.SessionData.AccessToken)
fmt.Printf("DPoP Key: %s\n\n", result.SessionData.DPoPPrivateKeyMultibase)
// Generate DPoP proof for deleteRecord endpoint if all params provided
if *repo != "" && *rkey != "" {
deleteURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.deleteRecord?repo=%s&collection=%s&rkey=%s",
*holdURL, *repo, *collection, *rkey)
dpopProof, err := generateDPoPProof(result.Session, "POST", deleteURL)
if err != nil {
log.Fatalf("❌ Failed to generate DPoP proof: %v\n", err)
}
fmt.Printf("📋 Ready-to-use curl command:\n\n")
fmt.Printf("curl -X POST \\\n")
fmt.Printf(" -H \"Authorization: DPoP %s\" \\\n", result.SessionData.AccessToken)
fmt.Printf(" -H \"DPoP: %s\" \\\n", dpopProof)
fmt.Printf(" \"%s\"\n", deleteURL)
} else {
fmt.Printf("💡 To generate a curl command for deleteRecord, provide:\n")
fmt.Printf(" --repo <did>\n")
fmt.Printf(" --collection <collection>\n")
fmt.Printf(" --rkey <rkey>\n")
}
}
// generateDPoPProof generates a DPoP proof JWT for a specific request
func generateDPoPProof(session *indigo_oauth.ClientSession, method, reqURL string) (string, error) {
// Use the session's NewHostDPoP method to generate the proof
return session.NewHostDPoP(method, reqURL)
}
// sha256Hash computes SHA-256 hash and returns base64url-encoded string
func sha256Hash(data []byte) string {
hash := sha256.Sum256(data)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
+139 -12
View File
@@ -225,6 +225,8 @@ func (h *XRPCHandler) HandleGetRecord(w http.ResponseWriter, r *http.Request) {
}
// HandleListRecords lists records in a collection
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-list-records
// Supports pagination via limit, cursor, and reverse parameters
func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -244,6 +246,20 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
return
}
// Parse pagination parameters (per spec)
limit := 50 // default
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
parsedLimit, err := strconv.Atoi(limitStr)
if err != nil || parsedLimit < 1 || parsedLimit > 100 {
http.Error(w, "invalid limit (must be 1-100)", http.StatusBadRequest)
return
}
limit = parsedLimit
}
cursor := r.URL.Query().Get("cursor")
reverse := r.URL.Query().Get("reverse") == "true"
// Generic implementation using repo.ForEach
session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
if err != nil {
@@ -271,7 +287,10 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
return
}
var records []map[string]any
// Initialize as empty slice (not nil) to ensure JSON encodes as [] not null
records := []map[string]any{}
var nextCursor string
skipUntilCursor := cursor != ""
// Iterate over all records in the collection
err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error {
@@ -292,6 +311,21 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
return repo.ErrDoneIterating // Stop walking the tree
}
// Handle cursor-based pagination
if skipUntilCursor {
if rkey == cursor {
skipUntilCursor = false // Found cursor, start including records after this
}
return nil // Skip this record
}
// Check if we've hit the limit
if len(records) >= limit {
// Set next cursor to current rkey
nextCursor = rkey
return repo.ErrDoneIterating // Stop iteration
}
// Get the record bytes
recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
if err != nil {
@@ -313,9 +347,10 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
})
if err != nil {
// ErrDoneIterating is expected when we stop walking early (reached collection boundary)
if err == repo.ErrDoneIterating {
// Successfully stopped at collection boundary, continue with collected records
// ErrDoneIterating is expected when we stop walking early (reached collection boundary or hit limit)
// Check using strings.Contains because the error may be wrapped
if err == repo.ErrDoneIterating || strings.Contains(err.Error(), "done iterating") {
// Successfully stopped at collection boundary or hit pagination limit, continue with collected records
} else if strings.Contains(err.Error(), "not found") {
// If the collection doesn't exist yet, return empty list
records = []map[string]any{}
@@ -325,31 +360,56 @@ func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request)
}
}
// Handle reverse order if requested
if reverse && len(records) > 0 {
// Reverse the slice
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
}
response := map[string]any{
"records": records,
}
// Include cursor in response if there are more records
if nextCursor != "" {
response["cursor"] = nextCursor
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleDeleteRecord deletes a record from the repository
// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-delete-record
// Accepts JSON input with repo, collection, rkey, and optional swap parameters
func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
repoDID := r.URL.Query().Get("repo")
collection := r.URL.Query().Get("collection")
rkey := r.URL.Query().Get("rkey")
// Parse JSON body (per spec - input is in body, not query params)
var input struct {
Repo string `json:"repo"`
Collection string `json:"collection"`
Rkey string `json:"rkey"`
SwapRecord *string `json:"swapRecord,omitempty"` // Optional CID for compare-and-swap
SwapCommit *string `json:"swapCommit,omitempty"` // Optional CID for compare-and-swap
}
if repoDID == "" || collection == "" || rkey == "" {
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, fmt.Sprintf("invalid JSON body: %v", err), http.StatusBadRequest)
return
}
if input.Repo == "" || input.Collection == "" || input.Rkey == "" {
http.Error(w, "missing required parameters", http.StatusBadRequest)
return
}
if repoDID != h.pds.DID() {
if input.Repo != h.pds.DID() {
http.Error(w, "invalid repo", http.StatusBadRequest)
return
}
@@ -361,8 +421,58 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request)
return
}
// TODO: Implement swap record/commit validation
// For now, if swap parameters are provided, we should validate them
// against the current record/commit CID before deleting
if input.SwapRecord != nil || input.SwapCommit != nil {
// Parse swap CIDs
var swapRecordCID, swapCommitCID cid.Cid
if input.SwapRecord != nil {
swapRecordCID, err = cid.Decode(*input.SwapRecord)
if err != nil {
http.Error(w, "invalid swapRecord CID", http.StatusBadRequest)
return
}
}
if input.SwapCommit != nil {
swapCommitCID, err = cid.Decode(*input.SwapCommit)
if err != nil {
http.Error(w, "invalid swapCommit CID", http.StatusBadRequest)
return
}
}
// Validate swap conditions
if input.SwapRecord != nil {
// Get current record CID
currentCID, _, err := h.pds.repomgr.GetRecord(r.Context(), h.pds.uid, input.Collection, input.Rkey, cid.Undef)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "record not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("failed to get current record: %v", err), http.StatusInternalServerError)
}
return
}
if !currentCID.Equals(swapRecordCID) {
// Swap failed - record CID doesn't match
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"error": "InvalidSwap",
"message": "record CID does not match swapRecord",
})
return
}
}
// SwapCommit validation would require checking the repo head CID
// For now, we'll skip this as it's complex and not critical for MVP
_ = swapCommitCID
}
// Delete the record using repomgr
err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, collection, rkey)
err = h.pds.repomgr.DeleteRecord(r.Context(), h.pds.uid, input.Collection, input.Rkey)
if err != nil {
if strings.Contains(err.Error(), "not found") {
http.Error(w, "record not found", http.StatusNotFound)
@@ -372,9 +482,26 @@ func (h *XRPCHandler) HandleDeleteRecord(w http.ResponseWriter, r *http.Request)
return
}
// Return success response
// Get commit info for response (per spec)
// The spec requires returning commit metadata
head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
return
}
rev, err := h.pds.repomgr.GetRepoRev(r.Context(), h.pds.uid)
if err != nil {
http.Error(w, fmt.Sprintf("failed to get repo rev: %v", err), http.StatusInternalServerError)
return
}
// Return commit response (per spec)
response := map[string]any{
"success": true,
"commit": map[string]any{
"cid": head.String(),
"rev": rev,
},
}
w.Header().Set("Content-Type", "application/json")
File diff suppressed because it is too large Load Diff