mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
588 lines
16 KiB
Go
588 lines
16 KiB
Go
package pds
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
)
|
|
|
|
// Tests for authorization functions in auth.go
|
|
|
|
// mockPDSClient is a mock HTTP client that simulates a PDS server
|
|
// It validates DPoP tokens and returns session information
|
|
type mockPDSClient struct{}
|
|
|
|
func (m *mockPDSClient) Do(req *http.Request) (*http.Response, error) {
|
|
// Verify request is for getSession endpoint
|
|
if !strings.Contains(req.URL.Path, "/xrpc/com.atproto.server.getSession") {
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Body: http.NoBody,
|
|
}, nil
|
|
}
|
|
|
|
// Verify DPoP headers are present
|
|
authHeader := req.Header.Get("Authorization")
|
|
dpopHeader := req.Header.Get("DPoP")
|
|
|
|
if authHeader == "" || dpopHeader == "" {
|
|
return &http.Response{
|
|
StatusCode: http.StatusUnauthorized,
|
|
Body: http.NoBody,
|
|
}, nil
|
|
}
|
|
|
|
// Extract access token from Authorization header
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 || parts[0] != "DPoP" {
|
|
return &http.Response{
|
|
StatusCode: http.StatusUnauthorized,
|
|
Body: http.NoBody,
|
|
}, nil
|
|
}
|
|
|
|
accessToken := parts[1]
|
|
|
|
// Parse token to extract DID
|
|
did, _, err := extractDIDFromToken(accessToken)
|
|
if err != nil {
|
|
return &http.Response{
|
|
StatusCode: http.StatusBadRequest,
|
|
Body: http.NoBody,
|
|
}, nil
|
|
}
|
|
|
|
// Return session response
|
|
session := SessionResponse{
|
|
DID: did,
|
|
Handle: strings.Replace(did, "did:plc:", "", 1) + ".test",
|
|
}
|
|
|
|
body, _ := json.Marshal(session)
|
|
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Body: io.NopCloser(strings.NewReader(string(body))),
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
}, nil
|
|
}
|
|
|
|
// DPoPTestHelper provides utilities for creating valid DPoP requests in tests
|
|
type DPoPTestHelper struct {
|
|
privKey atcrypto.PrivateKey
|
|
did string
|
|
pdsURL string
|
|
}
|
|
|
|
// NewDPoPTestHelper creates a new test helper for the given DID and PDS
|
|
func NewDPoPTestHelper(did, pdsURL string) (*DPoPTestHelper, error) {
|
|
// Generate a test P-256 key (required for OAuth DPoP)
|
|
// Note: ATProto uses K-256 for DID keys, but OAuth DPoP requires P-256
|
|
privKey, err := atcrypto.GeneratePrivateKeyP256()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate key: %w", err)
|
|
}
|
|
|
|
return &DPoPTestHelper{
|
|
privKey: privKey,
|
|
did: did,
|
|
pdsURL: pdsURL,
|
|
}, nil
|
|
}
|
|
|
|
// CreateAccessToken creates a mock OAuth access token for testing
|
|
// This mimics what a real PDS would issue
|
|
func (h *DPoPTestHelper) CreateAccessToken() (string, error) {
|
|
// Create access token claims
|
|
claims := map[string]any{
|
|
"sub": h.did, // Subject (DID)
|
|
"iss": h.pdsURL, // Issuer (PDS URL)
|
|
"aud": "atcr", // Audience
|
|
"iat": time.Now().Unix(), // Issued at
|
|
"exp": time.Now().Add(1 * time.Hour).Unix(), // Expires in 1 hour
|
|
}
|
|
|
|
// For testing, we create a valid JWT structure without actually validating the signature
|
|
// The ValidateDPoPRequest in real use would validate this by calling the PDS
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256K","typ":"JWT"}`))
|
|
payload, err := json.Marshal(claims)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal claims: %w", err)
|
|
}
|
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
|
|
|
// Create a mock signature (in real use, the PDS validates this)
|
|
signature := base64.RawURLEncoding.EncodeToString([]byte("mock-signature-for-testing"))
|
|
|
|
tokenString := fmt.Sprintf("%s.%s.%s", header, encodedPayload, signature)
|
|
return tokenString, nil
|
|
}
|
|
|
|
// CreateDPoPProof creates a DPoP proof JWT for the given HTTP request
|
|
func (h *DPoPTestHelper) CreateDPoPProof(method, url string) (string, error) {
|
|
return oauth.NewAuthDPoP(method, url, "", h.privKey)
|
|
}
|
|
|
|
// AddDPoPToRequest adds proper DPoP headers to an HTTP request
|
|
func (h *DPoPTestHelper) AddDPoPToRequest(req *http.Request) error {
|
|
// Create access token
|
|
accessToken, err := h.CreateAccessToken()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create access token: %w", err)
|
|
}
|
|
|
|
// Create DPoP proof for this specific request
|
|
dpopProof, err := h.CreateDPoPProof(req.Method, req.URL.String())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create DPoP proof: %w", err)
|
|
}
|
|
|
|
// Add headers
|
|
req.Header.Set("Authorization", "DPoP "+accessToken)
|
|
req.Header.Set("DPoP", dpopProof)
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddTestDPoP is a quick helper for common test case: owner with standard PDS
|
|
func AddTestDPoP(req *http.Request, did, pdsURL string) error {
|
|
helper, err := NewDPoPTestHelper(did, pdsURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return helper.AddDPoPToRequest(req)
|
|
}
|
|
|
|
// TestValidateBlobWriteAccess_Owner tests that the hold owner has write access
|
|
func TestValidateBlobWriteAccess_Owner(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap with owner
|
|
err := pds.Bootstrap(ctx, ownerDID, true, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Create DPoP helper for owner
|
|
dpopHelper, err := NewDPoPTestHelper(ownerDID, "https://test-pds.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create DPoP helper: %v", err)
|
|
}
|
|
|
|
// Create request with proper DPoP tokens
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
|
|
t.Fatalf("Failed to add DPoP to request: %v", err)
|
|
}
|
|
|
|
// Use mock PDS client
|
|
mockClient := &mockPDSClient{}
|
|
|
|
// Test owner has write access
|
|
user, err := ValidateBlobWriteAccess(req, pds, mockClient)
|
|
if err != nil {
|
|
t.Errorf("Expected owner to have write access, got error: %v", err)
|
|
}
|
|
|
|
if user == nil {
|
|
t.Fatal("Expected non-nil user")
|
|
}
|
|
|
|
if user.DID != ownerDID {
|
|
t.Errorf("Expected DID %s, got %s", ownerDID, user.DID)
|
|
}
|
|
|
|
if !user.Authorized {
|
|
t.Error("Expected user to be authorized")
|
|
}
|
|
}
|
|
|
|
// TestValidateBlobWriteAccess_CrewPermissions tests crew permission checking
|
|
func TestValidateBlobWriteAccess_CrewPermissions(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap
|
|
err := pds.Bootstrap(ctx, ownerDID, true, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Add crew member with blob:write permission
|
|
writerDID := "did:plc:writer123"
|
|
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to add crew member: %v", err)
|
|
}
|
|
|
|
// Add crew member without blob:write permission
|
|
readerDID := "did:plc:reader123"
|
|
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to add crew member: %v", err)
|
|
}
|
|
|
|
mockClient := &mockPDSClient{}
|
|
|
|
// Test writer (has blob:write permission) can write
|
|
t.Run("crew with blob:write can write", func(t *testing.T) {
|
|
dpopHelper, err := NewDPoPTestHelper(writerDID, "https://test-pds.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create DPoP helper: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
|
|
t.Fatalf("Failed to add DPoP to request: %v", err)
|
|
}
|
|
|
|
user, err := ValidateBlobWriteAccess(req, pds, mockClient)
|
|
if err != nil {
|
|
t.Errorf("Expected writer to have write access, got error: %v", err)
|
|
}
|
|
|
|
if user == nil || user.DID != writerDID {
|
|
t.Errorf("Expected user DID %s, got %v", writerDID, user)
|
|
}
|
|
})
|
|
|
|
// Test reader (no blob:write permission) cannot write
|
|
t.Run("crew without blob:write cannot write", func(t *testing.T) {
|
|
dpopHelper, err := NewDPoPTestHelper(readerDID, "https://test-pds.example.com")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create DPoP helper: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
|
|
t.Fatalf("Failed to add DPoP to request: %v", err)
|
|
}
|
|
|
|
_, err = ValidateBlobWriteAccess(req, pds, mockClient)
|
|
if err == nil {
|
|
t.Error("Expected reader without blob:write permission to be denied")
|
|
}
|
|
|
|
if !strings.Contains(err.Error(), "blob:write") {
|
|
t.Errorf("Expected error about blob:write permission, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidateBlobReadAccess_PublicHold tests public hold access
|
|
func TestValidateBlobReadAccess_PublicHold(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap with public=true
|
|
err := pds.Bootstrap(ctx, ownerDID, true, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Verify captain record has public=true
|
|
_, captain, err := pds.GetCaptainRecord(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get captain record: %v", err)
|
|
}
|
|
|
|
if !captain.Public {
|
|
t.Error("Expected public=true for captain record")
|
|
}
|
|
|
|
// Create request without auth headers (anonymous user)
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
|
|
// This should return nil (public access allowed) for public holds
|
|
user, err := ValidateBlobReadAccess(req, pds, nil)
|
|
if err != nil {
|
|
t.Errorf("Expected public access for public hold, got error: %v", err)
|
|
}
|
|
|
|
// nil user indicates public access
|
|
if user != nil {
|
|
t.Error("Expected nil user for public access")
|
|
}
|
|
}
|
|
|
|
// TestValidateBlobReadAccess_PrivateHold tests private hold access
|
|
func TestValidateBlobReadAccess_PrivateHold(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap with public=false
|
|
err := pds.Bootstrap(ctx, ownerDID, false, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Update captain to be private
|
|
_, err = pds.UpdateCaptainRecord(ctx, false, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to update captain record: %v", err)
|
|
}
|
|
|
|
// Verify captain record has public=false
|
|
_, captain, err := pds.GetCaptainRecord(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get captain record: %v", err)
|
|
}
|
|
|
|
if captain.Public {
|
|
t.Error("Expected public=false for captain record")
|
|
}
|
|
|
|
// Create request without auth headers (anonymous user)
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
|
|
// This should return error (auth required) for private holds
|
|
user, err := ValidateBlobReadAccess(req, pds, nil)
|
|
if err == nil {
|
|
t.Error("Expected error for private hold without auth")
|
|
}
|
|
|
|
if user != nil {
|
|
t.Error("Expected nil user when auth fails")
|
|
}
|
|
}
|
|
|
|
// TestValidateOwnerOrCrewAdmin tests admin permission checking
|
|
func TestValidateOwnerOrCrewAdmin(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap
|
|
err := pds.Bootstrap(ctx, ownerDID, true, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Add crew member with crew:admin permission
|
|
adminDID := "did:plc:admin123"
|
|
_, err = pds.AddCrewMember(ctx, adminDID, "admin", []string{"crew:admin", "blob:write", "blob:read"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to add crew admin: %v", err)
|
|
}
|
|
|
|
// Add crew member without crew:admin permission
|
|
writerDID := "did:plc:writer123"
|
|
_, err = pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
|
|
if err != nil {
|
|
t.Fatalf("Failed to add crew writer: %v", err)
|
|
}
|
|
|
|
// Verify crew records were created
|
|
crew, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to list crew members: %v", err)
|
|
}
|
|
|
|
// Verify admin has crew:admin permission
|
|
hasAdminPermission := false
|
|
for _, member := range crew {
|
|
if member.Record.Member == adminDID {
|
|
if slices.Contains(member.Record.Permissions, "crew:admin") {
|
|
hasAdminPermission = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasAdminPermission {
|
|
t.Error("Admin crew member should have crew:admin permission")
|
|
}
|
|
|
|
// Verify writer does NOT have crew:admin permission
|
|
writerHasAdminPermission := false
|
|
for _, member := range crew {
|
|
if member.Record.Member == writerDID {
|
|
if slices.Contains(member.Record.Permissions, "crew:admin") {
|
|
writerHasAdminPermission = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if writerHasAdminPermission {
|
|
t.Error("Writer crew member should NOT have crew:admin permission")
|
|
}
|
|
|
|
// Test that function requires auth (will fail without DPoP tokens)
|
|
req := httptest.NewRequest(http.MethodPost, "/test", nil)
|
|
_, err = ValidateOwnerOrCrewAdmin(req, pds, nil)
|
|
if err == nil {
|
|
t.Error("Expected error for missing auth headers")
|
|
}
|
|
}
|
|
|
|
// TestCrewPermissions tests various permission combinations
|
|
func TestCrewPermissions(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap
|
|
err := pds.Bootstrap(ctx, ownerDID, true, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
did string
|
|
role string
|
|
permissions []string
|
|
}{
|
|
{
|
|
name: "full admin",
|
|
did: "did:plc:fulladmin",
|
|
role: "admin",
|
|
permissions: []string{"crew:admin", "blob:write", "blob:read"},
|
|
},
|
|
{
|
|
name: "writer only",
|
|
did: "did:plc:writer",
|
|
role: "writer",
|
|
permissions: []string{"blob:write"},
|
|
},
|
|
{
|
|
name: "reader only",
|
|
did: "did:plc:reader",
|
|
role: "reader",
|
|
permissions: []string{"blob:read"},
|
|
},
|
|
{
|
|
name: "read-write",
|
|
did: "did:plc:readwrite",
|
|
role: "editor",
|
|
permissions: []string{"blob:read", "blob:write"},
|
|
},
|
|
}
|
|
|
|
// Add all crew members
|
|
for _, tt := range tests {
|
|
_, err := pds.AddCrewMember(ctx, tt.did, tt.role, tt.permissions)
|
|
if err != nil {
|
|
t.Fatalf("Failed to add crew member %s: %v", tt.name, err)
|
|
}
|
|
}
|
|
|
|
// Verify all crew members were created
|
|
crew, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to list crew members: %v", err)
|
|
}
|
|
|
|
// Should have: 1 owner (from bootstrap) + 4 test crew members
|
|
expectedCount := len(tests) + 1
|
|
if len(crew) != expectedCount {
|
|
t.Errorf("Expected %d crew members (owner + %d test members), got %d",
|
|
expectedCount, len(tests), len(crew))
|
|
}
|
|
|
|
// Verify each crew member has the expected permissions
|
|
for _, tt := range tests {
|
|
found := false
|
|
for _, member := range crew {
|
|
if member.Record.Member == tt.did {
|
|
found = true
|
|
|
|
// Check that all expected permissions are present
|
|
for _, expectedPerm := range tt.permissions {
|
|
hasPerm := slices.Contains(member.Record.Permissions, expectedPerm)
|
|
if !hasPerm {
|
|
t.Errorf("Crew member %s missing expected permission %s",
|
|
tt.name, expectedPerm)
|
|
}
|
|
}
|
|
|
|
// Verify role
|
|
if member.Record.Role != tt.role {
|
|
t.Errorf("Crew member %s has role %s, expected %s",
|
|
tt.name, member.Record.Role, tt.role)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Errorf("Crew member %s not found in list", tt.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCaptainRecordSettings tests captain record public/allowAllCrew settings
|
|
func TestCaptainRecordSettings(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
public bool
|
|
allowAllCrew bool
|
|
}{
|
|
{
|
|
name: "public hold, crew approval required",
|
|
public: true,
|
|
allowAllCrew: false,
|
|
},
|
|
{
|
|
name: "public hold, open crew",
|
|
public: true,
|
|
allowAllCrew: true,
|
|
},
|
|
{
|
|
name: "private hold, crew approval required",
|
|
public: false,
|
|
allowAllCrew: false,
|
|
},
|
|
{
|
|
name: "private hold, open crew",
|
|
public: false,
|
|
allowAllCrew: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
|
|
ownerDID := "did:plc:owner123"
|
|
|
|
// Bootstrap with specified settings
|
|
err := pds.Bootstrap(ctx, ownerDID, tt.public, tt.allowAllCrew)
|
|
if err != nil {
|
|
t.Fatalf("Failed to bootstrap PDS: %v", err)
|
|
}
|
|
|
|
// Verify captain record has expected settings
|
|
_, captain, err := pds.GetCaptainRecord(ctx)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get captain record: %v", err)
|
|
}
|
|
|
|
if captain.Public != tt.public {
|
|
t.Errorf("Expected public=%v, got %v", tt.public, captain.Public)
|
|
}
|
|
|
|
if captain.AllowAllCrew != tt.allowAllCrew {
|
|
t.Errorf("Expected allowAllCrew=%v, got %v", tt.allowAllCrew, captain.AllowAllCrew)
|
|
}
|
|
|
|
if captain.Owner != ownerDID {
|
|
t.Errorf("Expected owner %s, got %s", ownerDID, captain.Owner)
|
|
}
|
|
})
|
|
}
|
|
}
|