unit tests

This commit is contained in:
Evan Jarrett
2025-10-28 17:40:11 -05:00
parent 93b1d0d4ba
commit b0799cd94d
56 changed files with 9857 additions and 58 deletions
+195
View File
@@ -0,0 +1,195 @@
package token
import (
"testing"
"time"
)
func TestGetServiceToken_NotCached(t *testing.T) {
// Clear cache first
globalServiceTokensMu.Lock()
globalServiceTokens = make(map[string]*serviceTokenEntry)
globalServiceTokensMu.Unlock()
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
token, expiresAt := GetServiceToken(did, holdDID)
if token != "" {
t.Errorf("Expected empty token for uncached entry, got %q", token)
}
if !expiresAt.IsZero() {
t.Error("Expected zero time for uncached entry")
}
}
func TestSetServiceToken_ManualExpiry(t *testing.T) {
// Clear cache first
globalServiceTokensMu.Lock()
globalServiceTokens = make(map[string]*serviceTokenEntry)
globalServiceTokensMu.Unlock()
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
token := "invalid_jwt_token" // Will fall back to 50s default
// This should succeed with default 50s TTL since JWT parsing will fail
err := SetServiceToken(did, holdDID, token)
if err != nil {
t.Fatalf("SetServiceToken() error = %v", err)
}
// Verify token was cached
cachedToken, expiresAt := GetServiceToken(did, holdDID)
if cachedToken != token {
t.Errorf("Expected token %q, got %q", token, cachedToken)
}
if expiresAt.IsZero() {
t.Error("Expected non-zero expiry time")
}
// Expiry should be approximately 50s from now (with 10s margin subtracted in some cases)
expectedExpiry := time.Now().Add(50 * time.Second)
diff := expiresAt.Sub(expectedExpiry)
if diff < -5*time.Second || diff > 5*time.Second {
t.Errorf("Expiry time off by %v (expected ~50s from now)", diff)
}
}
func TestGetServiceToken_Expired(t *testing.T) {
// Manually insert an expired token
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
cacheKey := did + ":" + holdDID
globalServiceTokensMu.Lock()
globalServiceTokens[cacheKey] = &serviceTokenEntry{
token: "expired_token",
expiresAt: time.Now().Add(-1 * time.Hour), // 1 hour ago
}
globalServiceTokensMu.Unlock()
// Try to get - should return empty since expired
token, expiresAt := GetServiceToken(did, holdDID)
if token != "" {
t.Errorf("Expected empty token for expired entry, got %q", token)
}
if !expiresAt.IsZero() {
t.Error("Expected zero time for expired entry")
}
// Verify token was removed from cache
globalServiceTokensMu.RLock()
_, exists := globalServiceTokens[cacheKey]
globalServiceTokensMu.RUnlock()
if exists {
t.Error("Expected expired token to be removed from cache")
}
}
func TestInvalidateServiceToken(t *testing.T) {
// Set a token
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
token := "test_token"
err := SetServiceToken(did, holdDID, token)
if err != nil {
t.Fatalf("SetServiceToken() error = %v", err)
}
// Verify it's cached
cachedToken, _ := GetServiceToken(did, holdDID)
if cachedToken != token {
t.Fatal("Token should be cached")
}
// Invalidate
InvalidateServiceToken(did, holdDID)
// Verify it's gone
cachedToken, _ = GetServiceToken(did, holdDID)
if cachedToken != "" {
t.Error("Expected token to be invalidated")
}
}
func TestCleanExpiredTokens(t *testing.T) {
// Clear cache first
globalServiceTokensMu.Lock()
globalServiceTokens = make(map[string]*serviceTokenEntry)
globalServiceTokensMu.Unlock()
// Add expired and valid tokens
globalServiceTokensMu.Lock()
globalServiceTokens["expired:hold1"] = &serviceTokenEntry{
token: "expired1",
expiresAt: time.Now().Add(-1 * time.Hour),
}
globalServiceTokens["valid:hold2"] = &serviceTokenEntry{
token: "valid1",
expiresAt: time.Now().Add(1 * time.Hour),
}
globalServiceTokensMu.Unlock()
// Clean expired
CleanExpiredTokens()
// Verify only valid token remains
globalServiceTokensMu.RLock()
_, expiredExists := globalServiceTokens["expired:hold1"]
_, validExists := globalServiceTokens["valid:hold2"]
globalServiceTokensMu.RUnlock()
if expiredExists {
t.Error("Expected expired token to be removed")
}
if !validExists {
t.Error("Expected valid token to remain")
}
}
func TestGetCacheStats(t *testing.T) {
// Clear cache first
globalServiceTokensMu.Lock()
globalServiceTokens = make(map[string]*serviceTokenEntry)
globalServiceTokensMu.Unlock()
// Add some tokens
globalServiceTokensMu.Lock()
globalServiceTokens["did1:hold1"] = &serviceTokenEntry{
token: "token1",
expiresAt: time.Now().Add(1 * time.Hour),
}
globalServiceTokens["did2:hold2"] = &serviceTokenEntry{
token: "token2",
expiresAt: time.Now().Add(1 * time.Hour),
}
globalServiceTokensMu.Unlock()
stats := GetCacheStats()
if stats == nil {
t.Fatal("Expected non-nil stats")
}
// GetCacheStats returns map[string]any with "total_entries" key
totalEntries, ok := stats["total_entries"].(int)
if !ok {
t.Fatalf("Expected total_entries in stats map, got: %v", stats)
}
if totalEntries != 2 {
t.Errorf("Expected 2 entries, got %d", totalEntries)
}
// Also check valid_tokens
validTokens, ok := stats["valid_tokens"].(int)
if !ok {
t.Fatal("Expected valid_tokens in stats map")
}
if validTokens != 2 {
t.Errorf("Expected 2 valid tokens, got %d", validTokens)
}
}
+77
View File
@@ -0,0 +1,77 @@
package token
import (
"testing"
"time"
"atcr.io/pkg/auth"
)
func TestNewClaims(t *testing.T) {
subject := "did:plc:user123"
issuer := "atcr.io"
audience := "registry"
expiration := 15 * time.Minute
access := []auth.AccessEntry{
{
Type: "repository",
Name: "alice/myapp",
Actions: []string{"pull", "push"},
},
}
claims := NewClaims(subject, issuer, audience, expiration, access)
if claims.Subject != subject {
t.Errorf("Expected subject %q, got %q", subject, claims.Subject)
}
if claims.Issuer != issuer {
t.Errorf("Expected issuer %q, got %q", issuer, claims.Issuer)
}
if len(claims.Audience) != 1 || claims.Audience[0] != audience {
t.Errorf("Expected audience [%q], got %v", audience, claims.Audience)
}
if claims.IssuedAt == nil {
t.Error("Expected IssuedAt to be set")
}
if claims.NotBefore == nil {
t.Error("Expected NotBefore to be set")
}
if claims.ExpiresAt == nil {
t.Error("Expected ExpiresAt to be set")
}
// Check expiration is approximately correct (within 1 second)
expectedExpiry := time.Now().Add(expiration)
actualExpiry := claims.ExpiresAt.Time
diff := actualExpiry.Sub(expectedExpiry)
if diff < -time.Second || diff > time.Second {
t.Errorf("Expected expiry around %v, got %v (diff: %v)", expectedExpiry, actualExpiry, diff)
}
if len(claims.Access) != 1 {
t.Errorf("Expected 1 access entry, got %d", len(claims.Access))
}
if len(claims.Access) > 0 {
if claims.Access[0].Type != "repository" {
t.Errorf("Expected type %q, got %q", "repository", claims.Access[0].Type)
}
if claims.Access[0].Name != "alice/myapp" {
t.Errorf("Expected name %q, got %q", "alice/myapp", claims.Access[0].Name)
}
}
}
func TestNewClaims_EmptyAccess(t *testing.T) {
claims := NewClaims("did:plc:user123", "atcr.io", "registry", 15*time.Minute, nil)
if claims.Access != nil {
t.Error("Expected Access to be nil when not provided")
}
}
+626
View File
@@ -0,0 +1,626 @@
package token
import (
"context"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"atcr.io/pkg/appview/db"
)
// setupTestDeviceStore creates an in-memory SQLite database for testing
func setupTestDeviceStore(t *testing.T) (*db.DeviceStore, *sql.DB) {
testDB, err := db.InitDB(":memory:")
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
return db.NewDeviceStore(testDB), testDB
}
// createTestDevice creates a device in the test database and returns its secret
// Requires both DeviceStore and sql.DB to insert user record first
func createTestDevice(t *testing.T, store *db.DeviceStore, testDB *sql.DB, did, handle string) string {
// First create a user record (required by foreign key constraint)
user := &db.User{
DID: did,
Handle: handle,
PDSEndpoint: "https://pds.example.com",
}
err := db.UpsertUser(testDB, user)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create pending authorization
pending, err := store.CreatePendingAuth("Test Device", "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("Failed to create pending auth: %v", err)
}
// Approve the pending authorization
secret, err := store.ApprovePending(pending.UserCode, did, handle)
if err != nil {
t.Fatalf("Failed to approve pending auth: %v", err)
}
return secret
}
func TestNewHandler(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
if handler == nil {
t.Fatal("Expected non-nil handler")
}
if handler.issuer == nil {
t.Error("Expected issuer to be set")
}
if handler.validator == nil {
t.Error("Expected validator to be initialized")
}
}
func TestHandler_SetPostAuthCallback(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
return nil
})
if handler.postAuthCallback == nil {
t.Error("Expected post-auth callback to be set")
}
}
func TestHandler_ServeHTTP_NoAuth(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
// Check for WWW-Authenticate header
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("Expected WWW-Authenticate header")
}
}
func TestHandler_ServeHTTP_WrongMethod(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// Try POST instead of GET
req := httptest.NewRequest(http.MethodPost, "/auth/token", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
func TestHandler_ServeHTTP_DeviceAuth_Valid(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create real device store with in-memory database
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Create request with device secret
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice.bsky.social", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
t.Logf("Response body: %s", w.Body.String())
}
// Parse response
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token")
}
if resp.AccessToken == "" {
t.Error("Expected non-empty access_token")
}
if resp.ExpiresIn == 0 {
t.Error("Expected non-zero expires_in")
}
// Verify token and access_token are the same
if resp.Token != resp.AccessToken {
t.Error("Expected token and access_token to be the same")
}
}
func TestHandler_ServeHTTP_DeviceAuth_Invalid(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create device store but don't add any devices
deviceStore, _ := setupTestDeviceStore(t)
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", "atcr_device_invalid")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestHandler_ServeHTTP_InvalidScope(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Invalid scope format (missing colons)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=invalid", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "invalid scope") {
t.Errorf("Expected error message to contain 'invalid scope', got: %s", body)
}
}
func TestHandler_ServeHTTP_AccessDenied(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Try to push to someone else's repository
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "access denied") {
t.Errorf("Expected error message to contain 'access denied', got: %s", body)
}
}
func TestHandler_ServeHTTP_WithCallback(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Set callback to track if it's called
callbackCalled := false
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
callbackCalled = true
// Note: We don't check the values because callback shouldn't be called for device auth
return nil
})
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Note: Callback is only called for app password auth, not device auth
// So callbackCalled should be false for this test
if callbackCalled {
t.Error("Expected callback NOT to be called for device auth")
}
}
func TestHandler_ServeHTTP_MultipleScopes(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Multiple scopes separated by space (URL encoded)
scopes := "repository%3Aalice.bsky.social%2Fapp1%3Apull+repository%3Aalice.bsky.social%2Fapp2%3Apush"
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope="+scopes, nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_WildcardScope(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Wildcard scope should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:*:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_NoScope(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// No scope parameter - should still work (empty access)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token even with no scope")
}
}
func TestGetBaseURL(t *testing.T) {
tests := []struct {
name string
host string
headers map[string]string
expectedURL string
}{
{
name: "simple host",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "http://registry.example.com",
},
{
name: "with TLS",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "https://registry.example.com", // Would need TLS in request
},
{
name: "with X-Forwarded-Host",
host: "internal-host",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
},
expectedURL: "http://registry.example.com",
},
{
name: "with X-Forwarded-Proto",
host: "registry.example.com",
headers: map[string]string{
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
{
name: "with both forwarded headers",
host: "internal",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = tt.host
for key, value := range tt.headers {
req.Header.Set(key, value)
}
// For TLS test
if tt.expectedURL == "https://registry.example.com" && len(tt.headers) == 0 {
req.TLS = &tls.ConnectionState{} // Non-nil TLS indicates HTTPS
}
baseURL := getBaseURL(req)
if baseURL != tt.expectedURL {
t.Errorf("Expected URL %q, got %q", tt.expectedURL, baseURL)
}
})
}
}
func TestTokenResponse_JSONFormat(t *testing.T) {
resp := TokenResponse{
Token: "jwt_token_here",
AccessToken: "jwt_token_here",
ExpiresIn: 900,
IssuedAt: "2025-01-01T00:00:00Z",
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Failed to marshal response: %v", err)
}
// Verify JSON structure
var decoded map[string]interface{}
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
if decoded["token"] != "jwt_token_here" {
t.Error("Expected token field in JSON")
}
if decoded["access_token"] != "jwt_token_here" {
t.Error("Expected access_token field in JSON")
}
if decoded["expires_in"] != float64(900) {
t.Error("Expected expires_in field in JSON")
}
if decoded["issued_at"] != "2025-01-01T00:00:00Z" {
t.Error("Expected issued_at field in JSON")
}
}
func TestHandler_ServeHTTP_AuthHeader(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// Test with manually constructed auth header
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
auth := base64.StdEncoding.EncodeToString([]byte("username:password"))
req.Header.Set("Authorization", "Basic "+auth)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Should fail because we don't have valid credentials, but we're testing the header parsing
if w.Code != http.StatusUnauthorized {
t.Logf("Got status %d (this is fine, we're just testing header parsing)", w.Code)
}
}
func TestHandler_ServeHTTP_ContentType(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %q", contentType)
}
}
func TestHandler_ServeHTTP_ExpiresIn(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
// Create issuer with specific expiration
expiration := 10 * time.Minute
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
expectedExpiresIn := int(expiration.Seconds())
if resp.ExpiresIn != expectedExpiresIn {
t.Errorf("Expected expires_in %d, got %d", expectedExpiresIn, resp.ExpiresIn)
}
}
func TestHandler_ServeHTTP_PullOnlyAccess(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Pull from someone else's repo should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d for pull-only access, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
+573
View File
@@ -0,0 +1,573 @@
package token
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"atcr.io/pkg/auth"
"github.com/golang-jwt/jwt/v5"
)
func TestNewIssuer_GeneratesKey(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
if issuer == nil {
t.Fatal("Expected non-nil issuer")
}
// Verify key file was created
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Error("Expected private key file to be created")
}
// Verify certificate file was created
certPath := filepath.Join(tmpDir, "private-key.crt")
if _, err := os.Stat(certPath); os.IsNotExist(err) {
t.Error("Expected certificate file to be created")
}
// Verify key file permissions (should be 0600)
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("Failed to stat key file: %v", err)
}
mode := info.Mode()
if mode.Perm() != 0600 {
t.Errorf("Expected key file permissions 0600, got %04o", mode.Perm())
}
// Verify issuer fields
if issuer.issuer != "atcr.io" {
t.Errorf("Expected issuer %q, got %q", "atcr.io", issuer.issuer)
}
if issuer.service != "registry" {
t.Errorf("Expected service %q, got %q", "registry", issuer.service)
}
if issuer.expiration != 15*time.Minute {
t.Errorf("Expected expiration %v, got %v", 15*time.Minute, issuer.expiration)
}
if issuer.privateKey == nil {
t.Error("Expected private key to be set")
}
if issuer.publicKey == nil {
t.Error("Expected public key to be set")
}
if issuer.certificate == nil {
t.Error("Expected certificate to be set")
}
}
func TestNewIssuer_LoadsExistingKey(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
// First create - generates key
issuer1, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("First NewIssuer() error = %v", err)
}
// Second create - should load existing key
issuer2, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("Second NewIssuer() error = %v", err)
}
// Compare public keys - should be the same
if issuer1.publicKey.N.Cmp(issuer2.publicKey.N) != 0 {
t.Error("Expected same public key when loading existing key")
}
if issuer1.publicKey.E != issuer2.publicKey.E {
t.Error("Expected same public key exponent when loading existing key")
}
}
func TestIssuer_Issue(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
subject := "did:plc:user123"
access := []auth.AccessEntry{
{
Type: "repository",
Name: "alice/myapp",
Actions: []string{"pull", "push"},
},
}
token, err := issuer.Issue(subject, access)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if token == "" {
t.Fatal("Expected non-empty token")
}
// Token should be a JWT (3 parts separated by dots)
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Errorf("Expected JWT with 3 parts, got %d parts", len(parts))
}
}
func TestIssuer_Issue_EmptyAccess(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
token, err := issuer.Issue("did:plc:user123", nil)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
if token == "" {
t.Fatal("Expected non-empty token even with nil access")
}
}
func TestIssuer_Issue_ValidateToken(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
subject := "did:plc:user123"
access := []auth.AccessEntry{
{
Type: "repository",
Name: "alice/myapp",
Actions: []string{"pull", "push"},
},
}
tokenString, err := issuer.Issue(subject, access)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
// Parse and validate the token
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return issuer.publicKey, nil
})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
if !token.Valid {
t.Error("Expected token to be valid")
}
claims, ok := token.Claims.(*Claims)
if !ok {
t.Fatal("Failed to cast claims to *Claims")
}
// Verify claims
if claims.Subject != subject {
t.Errorf("Expected subject %q, got %q", subject, claims.Subject)
}
if claims.Issuer != "atcr.io" {
t.Errorf("Expected issuer %q, got %q", "atcr.io", claims.Issuer)
}
if len(claims.Audience) != 1 || claims.Audience[0] != "registry" {
t.Errorf("Expected audience [%q], got %v", "registry", claims.Audience)
}
if len(claims.Access) != 1 {
t.Errorf("Expected 1 access entry, got %d", len(claims.Access))
}
if len(claims.Access) > 0 {
if claims.Access[0].Type != "repository" {
t.Errorf("Expected type %q, got %q", "repository", claims.Access[0].Type)
}
if claims.Access[0].Name != "alice/myapp" {
t.Errorf("Expected name %q, got %q", "alice/myapp", claims.Access[0].Name)
}
if len(claims.Access[0].Actions) != 2 {
t.Errorf("Expected 2 actions, got %d", len(claims.Access[0].Actions))
}
}
// Verify expiration is set and reasonable
if claims.ExpiresAt == nil {
t.Fatal("Expected ExpiresAt to be set")
}
expiresIn := time.Until(claims.ExpiresAt.Time)
if expiresIn < 14*time.Minute || expiresIn > 16*time.Minute {
t.Errorf("Expected expiration around 15 minutes, got %v", expiresIn)
}
}
func TestIssuer_Issue_X5CHeader(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
tokenString, err := issuer.Issue("did:plc:user123", nil)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
// Parse token to inspect header
token, _, err := jwt.NewParser().ParseUnverified(tokenString, &Claims{})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
// Check x5c header exists
x5c, ok := token.Header["x5c"]
if !ok {
t.Fatal("Expected x5c header in token")
}
// x5c should be a slice of base64-encoded certificates
x5cSlice, ok := x5c.([]interface{})
if !ok {
t.Fatal("Expected x5c to be a slice")
}
if len(x5cSlice) != 1 {
t.Errorf("Expected 1 certificate in x5c chain, got %d", len(x5cSlice))
}
// Decode and verify certificate
certStr, ok := x5cSlice[0].(string)
if !ok {
t.Fatal("Expected certificate to be a string")
}
certBytes, err := base64.StdEncoding.DecodeString(certStr)
if err != nil {
t.Fatalf("Failed to decode certificate: %v", err)
}
// Parse certificate
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
t.Fatalf("Failed to parse certificate: %v", err)
}
// Verify certificate is self-signed and matches our public key
if cert.Subject.CommonName != "ATCR Token Signing Certificate" {
t.Errorf("Expected CN %q, got %q", "ATCR Token Signing Certificate", cert.Subject.CommonName)
}
// Verify certificate's public key matches issuer's public key
certPubKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
t.Fatal("Expected RSA public key in certificate")
}
if certPubKey.N.Cmp(issuer.publicKey.N) != 0 {
t.Error("Certificate public key doesn't match issuer public key")
}
}
func TestIssuer_PublicKey(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
pubKey := issuer.PublicKey()
if pubKey == nil {
t.Fatal("Expected non-nil public key")
}
// Verify it's a valid RSA public key
if pubKey.N == nil {
t.Error("Expected public key modulus to be set")
}
if pubKey.E == 0 {
t.Error("Expected public key exponent to be set")
}
}
func TestIssuer_Expiration(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
expiration := 30 * time.Minute
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
if issuer.Expiration() != expiration {
t.Errorf("Expected expiration %v, got %v", expiration, issuer.Expiration())
}
}
func TestIssuer_ConcurrentIssue(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Issue tokens concurrently
const numGoroutines = 10
var wg sync.WaitGroup
wg.Add(numGoroutines)
tokens := make([]string, numGoroutines)
errors := make([]error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
subject := "did:plc:user" + string(rune('0'+idx))
token, err := issuer.Issue(subject, nil)
tokens[idx] = token
errors[idx] = err
}(i)
}
wg.Wait()
// Verify all tokens were issued successfully
for i, err := range errors {
if err != nil {
t.Errorf("Goroutine %d: Issue() error = %v", i, err)
}
}
for i, token := range tokens {
if token == "" {
t.Errorf("Goroutine %d: Expected non-empty token", i)
}
}
}
func TestNewIssuer_InvalidCertificate(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
// First generate key + cert
_, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("First NewIssuer() error = %v", err)
}
// Corrupt the certificate file
certPath := filepath.Join(tmpDir, "private-key.crt")
err = os.WriteFile(certPath, []byte("invalid certificate data"), 0644)
if err != nil {
t.Fatalf("Failed to corrupt certificate: %v", err)
}
// Try to create issuer again - should fail
_, err = NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err == nil {
t.Error("Expected error when certificate is invalid")
}
if !strings.Contains(err.Error(), "certificate") {
t.Errorf("Expected error message to mention certificate, got: %v", err)
}
}
func TestNewIssuer_MissingCertificate(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
// First generate key + cert
_, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("First NewIssuer() error = %v", err)
}
// Delete certificate but keep key
certPath := filepath.Join(tmpDir, "private-key.crt")
err = os.Remove(certPath)
if err != nil {
t.Fatalf("Failed to remove certificate: %v", err)
}
// Try to create issuer - should regenerate certificate
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() should regenerate certificate, got error: %v", err)
}
if issuer == nil {
t.Fatal("Expected non-nil issuer")
}
// Verify certificate was regenerated
if _, err := os.Stat(certPath); os.IsNotExist(err) {
t.Error("Expected certificate to be regenerated")
}
}
func TestLoadOrGenerateKey_InvalidPEM(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "invalid-key.pem")
// Write invalid PEM data
err := os.WriteFile(keyPath, []byte("not a valid PEM file"), 0600)
if err != nil {
t.Fatalf("Failed to write invalid PEM: %v", err)
}
// Try to load - should fail
_, err = NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err == nil {
t.Error("Expected error when loading invalid PEM")
}
}
func TestGenerateCertificate_ValidCertificate(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
certPath := filepath.Join(tmpDir, "private-key.crt")
// Generate issuer (which generates key and cert)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Read and parse the certificate
certPEM, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("Failed to read certificate: %v", err)
}
block, _ := pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" {
t.Fatal("Failed to decode certificate PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("Failed to parse certificate: %v", err)
}
// Verify certificate properties
if cert.Subject.CommonName != "ATCR Token Signing Certificate" {
t.Errorf("Expected CN %q, got %q", "ATCR Token Signing Certificate", cert.Subject.CommonName)
}
if len(cert.Subject.Organization) == 0 || cert.Subject.Organization[0] != "ATCR" {
t.Error("Expected Organization to be ATCR")
}
// Verify key usage
if cert.KeyUsage&x509.KeyUsageDigitalSignature == 0 {
t.Error("Expected certificate to have DigitalSignature key usage")
}
// Verify validity period (should be 10 years)
validityPeriod := cert.NotAfter.Sub(cert.NotBefore)
expectedPeriod := 10 * 365 * 24 * time.Hour
if validityPeriod < expectedPeriod-24*time.Hour || validityPeriod > expectedPeriod+24*time.Hour {
t.Errorf("Expected validity period around 10 years, got %v", validityPeriod)
}
// Verify certificate's public key matches issuer's public key
certPubKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
t.Fatal("Expected RSA public key in certificate")
}
if certPubKey.N.Cmp(issuer.publicKey.N) != 0 {
t.Error("Certificate public key doesn't match issuer public key")
}
// Verify certificate is self-signed
if err := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature); err != nil {
t.Errorf("Certificate is not properly self-signed: %v", err)
}
}
func TestIssuer_DifferentExpirations(t *testing.T) {
expirations := []time.Duration{
1 * time.Minute,
15 * time.Minute,
1 * time.Hour,
24 * time.Hour,
}
for _, expiration := range expirations {
t.Run(expiration.String(), func(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "private-key.pem")
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
tokenString, err := issuer.Issue("did:plc:user123", nil)
if err != nil {
t.Fatalf("Issue() error = %v", err)
}
// Parse token and verify expiration
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return issuer.publicKey, nil
})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
claims, ok := token.Claims.(*Claims)
if !ok {
t.Fatal("Failed to cast claims")
}
expiresIn := time.Until(claims.ExpiresAt.Time)
// Allow 2 second tolerance for test execution time
if expiresIn < expiration-2*time.Second || expiresIn > expiration+2*time.Second {
t.Errorf("Expected expiration around %v, got %v", expiration, expiresIn)
}
})
}
}
+27
View File
@@ -0,0 +1,27 @@
package token
import (
"context"
"testing"
)
func TestGetOrFetchServiceToken_NilRefresher(t *testing.T) {
ctx := context.Background()
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
pdsEndpoint := "https://pds.example.com"
// Test with nil refresher - should return error
_, err := GetOrFetchServiceToken(ctx, nil, did, holdDID, pdsEndpoint)
if err == nil {
t.Error("Expected error when refresher is nil")
}
expectedErrMsg := "refresher is nil"
if err.Error() != "refresher is nil (OAuth session required for service tokens)" {
t.Errorf("Expected error message to contain %q, got %q", expectedErrMsg, err.Error())
}
}
// Note: Full tests with mocked OAuth refresher and HTTP client will be added
// in the comprehensive test implementation phase