mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
980 lines
28 KiB
Go
980 lines
28 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
|
|
_, err := pds.UpdateCaptainRecord(ctx, false, 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) {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|