Files
at-container-registry/pkg/hold/pds/auth_test.go
T

1097 lines
32 KiB
Go

package pds
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"atcr.io/pkg/atproto"
"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, atproto.ServerGetSession) {
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)
}
// ServiceTokenTestHelper provides utilities for creating service tokens in tests
type ServiceTokenTestHelper struct {
privKey atcrypto.PrivateKey
issuerDID string // User's DID (issuer)
audienceDID string // Hold service DID (audience)
}
// NewServiceTokenTestHelper creates a new test helper for service tokens
func NewServiceTokenTestHelper(issuerDID, audienceDID string) (*ServiceTokenTestHelper, error) {
// Generate a K-256 key (standard for ATProto DID keys)
privKey, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
return &ServiceTokenTestHelper{
privKey: privKey,
issuerDID: issuerDID,
audienceDID: audienceDID,
}, nil
}
// CreateServiceToken creates a service token JWT signed by the issuer's private key
// This mimics what a PDS returns from com.atproto.server.getServiceAuth
func (h *ServiceTokenTestHelper) CreateServiceToken(expiry time.Time) (string, error) {
// Create JWT header
header := map[string]string{
"alg": "ES256K",
"typ": "JWT",
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("failed to marshal header: %w", err)
}
encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON)
// Create JWT claims
claims := map[string]any{
"iss": h.issuerDID,
"aud": h.audienceDID,
"exp": expiry.Unix(),
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("failed to marshal claims: %w", err)
}
encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create signature
signedData := []byte(encodedHeader + "." + encodedClaims)
signature, err := h.privKey.HashAndSign(signedData)
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
return encodedHeader + "." + encodedClaims + "." + encodedSignature, nil
}
// GetPublicKey returns the public key for this helper (for DID resolution mocking)
func (h *ServiceTokenTestHelper) GetPublicKey() (atcrypto.PublicKey, error) {
return h.privKey.PublicKey()
}
// AddServiceTokenToRequest adds a Bearer token to the request
func (h *ServiceTokenTestHelper) AddServiceTokenToRequest(req *http.Request, expiry time.Time) error {
token, err := h.CreateServiceToken(expiry)
if err != nil {
return fmt.Errorf("failed to create service token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
return nil
}
// TestValidateServiceToken_ValidToken tests validation of a properly formed service token
func TestValidateServiceToken_ValidToken(t *testing.T) {
// This test validates token structure, audience, and expiration
// Note: Full signature verification requires DID resolution, which is tested separately
issuerDID := "did:plc:user123"
holdDID := "did:web:hold01.atcr.io"
helper, err := NewServiceTokenTestHelper(issuerDID, holdDID)
if err != nil {
t.Fatalf("Failed to create test helper: %v", err)
}
// Create valid token with 1 hour expiry
expiry := time.Now().Add(1 * time.Hour)
req := httptest.NewRequest(http.MethodPost, "/test", nil)
if err := helper.AddServiceTokenToRequest(req, expiry); err != nil {
t.Fatalf("Failed to add service token: %v", err)
}
// For testing token parsing (without full signature verification), we can validate
// the token structure by checking Authorization header format
authHeader := req.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
t.Errorf("Expected Bearer token, got: %s", authHeader)
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
parts := strings.Split(tokenString, ".")
if len(parts) != 3 {
t.Errorf("Expected 3 JWT parts, got %d", len(parts))
}
// Decode and verify claims
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("Failed to decode claims: %v", err)
}
var claims map[string]any
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
t.Fatalf("Failed to unmarshal claims: %v", err)
}
// Verify issuer
if iss, ok := claims["iss"].(string); !ok || iss != issuerDID {
t.Errorf("Expected issuer %s, got %v", issuerDID, claims["iss"])
}
// Verify audience
if aud, ok := claims["aud"].(string); !ok || aud != holdDID {
t.Errorf("Expected audience %s, got %v", holdDID, claims["aud"])
}
// Verify expiration is set and in the future
if exp, ok := claims["exp"].(float64); !ok {
t.Error("Expected exp claim to be present")
} else if time.Unix(int64(exp), 0).Before(time.Now()) {
t.Error("Expected exp to be in the future")
}
}
// TestValidateServiceToken_ExpiredToken tests rejection of expired tokens
func TestValidateServiceToken_ExpiredToken(t *testing.T) {
issuerDID := "did:plc:user123"
holdDID := "did:web:hold01.atcr.io"
helper, err := NewServiceTokenTestHelper(issuerDID, holdDID)
if err != nil {
t.Fatalf("Failed to create test helper: %v", err)
}
// Create token that expired 1 hour ago
expiry := time.Now().Add(-1 * time.Hour)
token, err := helper.CreateServiceToken(expiry)
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
// ValidateServiceToken should reject expired tokens
// Note: This test would need DID resolution mocking for full integration
// For now, we verify the token structure indicates it's expired
parts := strings.Split(token, ".")
claimsJSON, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
json.Unmarshal(claimsJSON, &claims)
exp := int64(claims["exp"].(float64))
if time.Unix(exp, 0).After(time.Now()) {
t.Error("Expected token to be expired")
}
}
// TestValidateServiceToken_WrongAudience tests rejection of tokens with wrong audience
func TestValidateServiceToken_WrongAudience(t *testing.T) {
issuerDID := "did:plc:user123"
wrongHoldDID := "did:web:wrong-hold.example.com"
correctHoldDID := "did:web:hold01.atcr.io"
// Create token for wrong audience
helper, err := NewServiceTokenTestHelper(issuerDID, wrongHoldDID)
if err != nil {
t.Fatalf("Failed to create test helper: %v", err)
}
expiry := time.Now().Add(1 * time.Hour)
token, err := helper.CreateServiceToken(expiry)
if err != nil {
t.Fatalf("Failed to create token: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set("Authorization", "Bearer "+token)
// Verify token has wrong audience
parts := strings.Split(token, ".")
claimsJSON, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
json.Unmarshal(claimsJSON, &claims)
aud := claims["aud"].(string)
if aud == correctHoldDID {
t.Errorf("Expected token to have wrong audience, got correct audience")
}
if aud != wrongHoldDID {
t.Errorf("Expected audience %s, got %s", wrongHoldDID, aud)
}
}
// TestValidateServiceToken_MalformedToken tests rejection of malformed tokens
func TestValidateServiceToken_MalformedToken(t *testing.T) {
testCases := []struct {
name string
token string
}{
{
name: "not enough parts",
token: "header.payload",
},
{
name: "too many parts",
token: "header.payload.signature.extra",
},
{
name: "invalid base64",
token: "!!!invalid!!!.payload.signature",
},
{
name: "empty token",
token: "",
},
{
name: "not a jwt",
token: "this-is-not-a-jwt",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set("Authorization", "Bearer "+tc.token)
// Verify token is malformed
parts := strings.Split(tc.token, ".")
if len(parts) == 3 {
// Try to decode
_, err := base64.RawURLEncoding.DecodeString(parts[1])
if err == nil && tc.name != "not enough parts" && tc.name != "too many parts" {
t.Skip("Token format is actually valid for this test")
}
}
})
}
}
// TestValidateServiceToken_MissingAuthorization tests rejection when Authorization header is missing
func TestValidateServiceToken_MissingAuthorization(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/test", nil)
// Don't set Authorization header
holdDID := "did:web:hold01.atcr.io"
// ValidateServiceToken should fail with missing auth header
_, err := ValidateServiceToken(req, holdDID, http.DefaultClient)
if err == nil {
t.Error("Expected error for missing Authorization header")
}
if !strings.Contains(err.Error(), "Authorization") {
t.Errorf("Expected error about Authorization header, got: %v", err)
}
}
// TestValidateServiceToken_WrongScheme tests rejection of non-Bearer schemes
func TestValidateServiceToken_WrongScheme(t *testing.T) {
testCases := []struct {
name string
header string
}{
{
name: "DPoP scheme",
header: "DPoP some-token",
},
{
name: "Basic scheme",
header: "Basic dXNlcjpwYXNz",
},
{
name: "no scheme",
header: "just-a-token",
},
}
holdDID := "did:web:hold01.atcr.io"
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/test", nil)
req.Header.Set("Authorization", tc.header)
_, err := ValidateServiceToken(req, holdDID, http.DefaultClient)
if err == nil {
t.Error("Expected error for wrong authorization scheme")
}
// Error should mention either "Bearer" or "Authorization" (for malformed headers)
errMsg := err.Error()
if !strings.Contains(errMsg, "Bearer") && !strings.Contains(errMsg, "Authorization") {
t.Errorf("Expected error about Bearer scheme or Authorization header, got: %v", err)
}
})
}
}
// TestValidateBlobWriteAccess_ServiceToken_Owner tests owner write access via service token
func TestValidateBlobWriteAccess_ServiceToken_Owner(t *testing.T) {
ownerDID := "did:plc:owner123"
holdDID := "did:web:hold01.atcr.io"
_, _ = setupTestPDSWithBootstrap(t, ownerDID, true, false)
// Create service token for owner
helper, err := NewServiceTokenTestHelper(ownerDID, holdDID)
if err != nil {
t.Fatalf("Failed to create service token helper: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
expiry := time.Now().Add(1 * time.Hour)
if err := helper.AddServiceTokenToRequest(req, expiry); err != nil {
t.Fatalf("Failed to add service token: %v", err)
}
// Note: This test would need full DID resolution for signature verification
// For now, we verify the request has the correct Bearer token format
authHeader := req.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
t.Errorf("Expected Bearer token, got: %s", authHeader)
}
}
// TestValidateBlobWriteAccess_ServiceToken_CrewWithPermission tests crew write access via service token
func TestValidateBlobWriteAccess_ServiceToken_CrewWithPermission(t *testing.T) {
ownerDID := "did:plc:owner123"
writerDID := "did:plc:writer123"
holdDID := "did:web:hold01.atcr.io"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// Add crew member with blob:write permission
_, err := pds.AddCrewMember(ctx, writerDID, "writer", []string{"blob:write"})
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
// Create service token for crew member
helper, err := NewServiceTokenTestHelper(writerDID, holdDID)
if err != nil {
t.Fatalf("Failed to create service token helper: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
expiry := time.Now().Add(1 * time.Hour)
if err := helper.AddServiceTokenToRequest(req, expiry); err != nil {
t.Fatalf("Failed to add service token: %v", err)
}
// Verify request has Bearer token
authHeader := req.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
t.Errorf("Expected Bearer token, got: %s", authHeader)
}
// Verify crew member exists with correct permissions
crew, err := pds.ListCrewMembers(ctx)
if err != nil {
t.Fatalf("Failed to list crew: %v", err)
}
found := false
for _, member := range crew {
if member.Record.Member == writerDID {
found = true
if !slices.Contains(member.Record.Permissions, "blob:write") {
t.Error("Expected crew member to have blob:write permission")
}
}
}
if !found {
t.Error("Crew member not found in PDS")
}
}
// TestValidateBlobWriteAccess_ServiceToken_CrewWithoutPermission tests that crew without permission is rejected
func TestValidateBlobWriteAccess_ServiceToken_CrewWithoutPermission(t *testing.T) {
ownerDID := "did:plc:owner123"
readerDID := "did:plc:reader123"
holdDID := "did:web:hold01.atcr.io"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// Add crew member with blob:read permission only (no blob:write)
_, err := pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
// Create service token for crew member
helper, err := NewServiceTokenTestHelper(readerDID, holdDID)
if err != nil {
t.Fatalf("Failed to create service token helper: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/test", nil)
expiry := time.Now().Add(1 * time.Hour)
if err := helper.AddServiceTokenToRequest(req, expiry); err != nil {
t.Fatalf("Failed to add service token: %v", err)
}
// Verify crew member exists without blob:write
crew, err := pds.ListCrewMembers(ctx)
if err != nil {
t.Fatalf("Failed to list crew: %v", err)
}
for _, member := range crew {
if member.Record.Member == readerDID {
if slices.Contains(member.Record.Permissions, "blob:write") {
t.Error("Crew member should NOT have blob:write permission")
}
}
}
}
// TestValidateBlobWriteAccess_Owner tests that the hold owner has write access
func TestValidateBlobWriteAccess_Owner(t *testing.T) {
ownerDID := "did:plc:owner123"
pds, _ := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// 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) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// 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) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// 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) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, false, false)
// Update captain to be private
_, captain, err := pds.GetCaptainRecord(ctx)
if err != nil {
t.Fatalf("Failed to get captain record for update: %v", err)
}
captain.Public = false
captain.AllowAllCrew = false
captain.EnableBlueskyPosts = false
_, err = pds.UpdateCaptainRecord(ctx, captain)
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")
}
}
// TestValidateBlobReadAccess_BlobWriteImpliesRead tests that blob:write grants read access
func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, false, false)
// Verify captain record has public=false (private hold)
_, 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")
}
// Add crew member with ONLY blob:write permission (no blob:read)
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)
}
mockClient := &mockPDSClient{}
// Test writer (has only blob:write permission) can read
t.Run("crew with blob:write can read", 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.MethodGet, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
// This should SUCCEED because blob:write implies blob:read
user, err := ValidateBlobReadAccess(req, pds, mockClient)
if err != nil {
t.Errorf("Expected blob:write to grant read access, got error: %v", err)
}
if user == nil {
t.Error("Expected user to be returned for valid read access")
} else if user.DID != writerDID {
t.Errorf("Expected user DID %s, got %s", writerDID, user.DID)
}
})
// Also verify that crew with only blob:read still works
t.Run("crew with blob:read can read", func(t *testing.T) {
readerDID := "did:plc:reader123"
_, err = pds.AddCrewMember(ctx, readerDID, "reader", []string{"blob:read"})
if err != nil {
t.Fatalf("Failed to add crew reader: %v", err)
}
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.MethodGet, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
user, err := ValidateBlobReadAccess(req, pds, mockClient)
if err != nil {
t.Errorf("Expected blob:read to grant read access, got error: %v", err)
}
if user == nil {
t.Error("Expected user to be returned for valid read access")
} else if user.DID != readerDID {
t.Errorf("Expected user DID %s, got %s", readerDID, user.DID)
}
})
// Verify crew with neither permission cannot read
t.Run("crew without read or write cannot read", func(t *testing.T) {
noPermDID := "did:plc:noperm123"
_, err = pds.AddCrewMember(ctx, noPermDID, "noperm", []string{"crew:admin"})
if err != nil {
t.Fatalf("Failed to add crew member: %v", err)
}
dpopHelper, err := NewDPoPTestHelper(noPermDID, "https://test-pds.example.com")
if err != nil {
t.Fatalf("Failed to create DPoP helper: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if err := dpopHelper.AddDPoPToRequest(req); err != nil {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
_, err = ValidateBlobReadAccess(req, pds, mockClient)
if err == nil {
t.Error("Expected error for crew without read or write permission")
}
// Verify error message format
if !strings.Contains(err.Error(), "access denied for blob:read") {
t.Errorf("Expected structured error message, got: %v", err)
}
})
}
// TestValidateOwnerOrCrewAdmin tests admin permission checking
func TestValidateOwnerOrCrewAdmin(t *testing.T) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
// 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) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, true, false)
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) {
ownerDID := "did:plc:owner123"
pds, ctx := setupTestPDSWithBootstrap(t, ownerDID, tt.public, tt.allowAllCrew)
// 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)
}
})
}
}