package auth import ( "strings" "testing" ) func TestParseScope_Valid(t *testing.T) { tests := []struct { name string scopes []string expectedCount int expectedType string expectedName string expectedActions []string }{ { name: "repository with actions", scopes: []string{"repository:alice/myapp:pull,push"}, expectedCount: 1, expectedType: "repository", expectedName: "alice/myapp", expectedActions: []string{"pull", "push"}, }, { name: "repository without actions", scopes: []string{"repository:alice/myapp"}, expectedCount: 1, expectedType: "repository", expectedName: "alice/myapp", expectedActions: nil, }, { name: "wildcard repository", scopes: []string{"repository:*:pull,push"}, expectedCount: 1, expectedType: "repository", expectedName: "*", expectedActions: []string{"pull", "push"}, }, { name: "empty scope ignored", scopes: []string{""}, expectedCount: 0, }, { name: "multiple scopes", scopes: []string{"repository:alice/app1:pull", "repository:alice/app2:push"}, expectedCount: 2, expectedType: "repository", expectedName: "alice/app1", expectedActions: []string{"pull"}, }, { name: "single action", scopes: []string{"repository:alice/myapp:pull"}, expectedCount: 1, expectedType: "repository", expectedName: "alice/myapp", expectedActions: []string{"pull"}, }, { name: "three actions", scopes: []string{"repository:alice/myapp:pull,push,delete"}, expectedCount: 1, expectedType: "repository", expectedName: "alice/myapp", expectedActions: []string{"pull", "push", "delete"}, }, // Note: DIDs with colons cannot be used directly in scope strings due to // the colon delimiter. This is a known limitation. { name: "empty actions string", scopes: []string{"repository:alice/myapp:"}, expectedCount: 1, expectedType: "repository", expectedName: "alice/myapp", expectedActions: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { access, err := ParseScope(tt.scopes) if err != nil { t.Fatalf("ParseScope() error = %v", err) } if len(access) != tt.expectedCount { t.Errorf("Expected %d access entries, got %d", tt.expectedCount, len(access)) return } if tt.expectedCount > 0 { entry := access[0] if entry.Type != tt.expectedType { t.Errorf("Expected type %q, got %q", tt.expectedType, entry.Type) } if entry.Name != tt.expectedName { t.Errorf("Expected name %q, got %q", tt.expectedName, entry.Name) } if len(entry.Actions) != len(tt.expectedActions) { t.Errorf("Expected %d actions, got %d", len(tt.expectedActions), len(entry.Actions)) } for i, expectedAction := range tt.expectedActions { if i < len(entry.Actions) && entry.Actions[i] != expectedAction { t.Errorf("Expected action[%d] = %q, got %q", i, expectedAction, entry.Actions[i]) } } } }) } } func TestParseScope_Invalid(t *testing.T) { tests := []struct { name string scopes []string }{ { name: "missing colon", scopes: []string{"repository"}, }, { name: "too many parts", scopes: []string{"repository:name:actions:extra"}, }, { name: "single part only", scopes: []string{"invalid"}, }, { name: "four colons", scopes: []string{"a:b:c:d:e"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := ParseScope(tt.scopes) if err == nil { t.Error("Expected error for invalid scope format") } if !strings.Contains(err.Error(), "invalid scope") { t.Errorf("Expected error message to contain 'invalid scope', got: %v", err) } }) } } func TestParseScope_SpecialCharacters(t *testing.T) { tests := []struct { name string scope string expectedName string }{ { name: "hyphen in name", scope: "repository:alice-bob/my-app:pull", expectedName: "alice-bob/my-app", }, { name: "underscore in name", scope: "repository:alice_bob/my_app:pull", expectedName: "alice_bob/my_app", }, { name: "dot in name", scope: "repository:alice.bsky.social/myapp:pull", expectedName: "alice.bsky.social/myapp", }, { name: "numbers in name", scope: "repository:user123/app456:pull", expectedName: "user123/app456", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { access, err := ParseScope([]string{tt.scope}) if err != nil { t.Fatalf("ParseScope() error = %v", err) } if len(access) != 1 { t.Fatalf("Expected 1 access entry, got %d", len(access)) } if access[0].Name != tt.expectedName { t.Errorf("Expected name %q, got %q", tt.expectedName, access[0].Name) } }) } } func TestParseScope_MultipleScopes(t *testing.T) { scopes := []string{ "repository:alice/app1:pull", "repository:alice/app2:push", "repository:bob/app3:pull,push", } access, err := ParseScope(scopes) if err != nil { t.Fatalf("ParseScope() error = %v", err) } if len(access) != 3 { t.Fatalf("Expected 3 access entries, got %d", len(access)) } // Verify first entry if access[0].Name != "alice/app1" { t.Errorf("Expected first name %q, got %q", "alice/app1", access[0].Name) } if len(access[0].Actions) != 1 || access[0].Actions[0] != "pull" { t.Errorf("Expected first actions [pull], got %v", access[0].Actions) } // Verify second entry if access[1].Name != "alice/app2" { t.Errorf("Expected second name %q, got %q", "alice/app2", access[1].Name) } if len(access[1].Actions) != 1 || access[1].Actions[0] != "push" { t.Errorf("Expected second actions [push], got %v", access[1].Actions) } // Verify third entry if access[2].Name != "bob/app3" { t.Errorf("Expected third name %q, got %q", "bob/app3", access[2].Name) } if len(access[2].Actions) != 2 { t.Errorf("Expected third entry to have 2 actions, got %d", len(access[2].Actions)) } } func TestValidateAccess_Owner(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" tests := []struct { name string repoName string actions []string shouldErr bool errorMsg string }{ { name: "owner can push to own repo (by handle)", repoName: "alice.bsky.social/myapp", actions: []string{"push"}, shouldErr: false, }, { name: "owner can push to own repo (by DID)", repoName: "did:plc:alice123/myapp", actions: []string{"push"}, shouldErr: false, }, { name: "owner cannot push to others repo", repoName: "bob.bsky.social/myapp", actions: []string{"push"}, shouldErr: true, errorMsg: "cannot push", }, { name: "wildcard scope allowed", repoName: "*", actions: []string{"push", "pull"}, shouldErr: false, }, { name: "owner can pull from others repo", repoName: "bob.bsky.social/myapp", actions: []string{"pull"}, shouldErr: false, }, { name: "owner cannot delete others repo", repoName: "bob.bsky.social/myapp", actions: []string{"delete"}, shouldErr: true, errorMsg: "cannot delete", }, { name: "multiple actions with push fails for others", repoName: "bob.bsky.social/myapp", actions: []string{"pull", "push"}, shouldErr: true, }, { name: "empty repository name", repoName: "", actions: []string{"push"}, shouldErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { access := []AccessEntry{ { Type: "repository", Name: tt.repoName, Actions: tt.actions, }, } err := ValidateAccess(userDID, userHandle, access) if tt.shouldErr && err == nil { t.Error("Expected error but got none") } if !tt.shouldErr && err != nil { t.Errorf("Expected no error but got: %v", err) } if tt.shouldErr && err != nil && tt.errorMsg != "" { if !strings.Contains(err.Error(), tt.errorMsg) { t.Errorf("Expected error to contain %q, got: %v", tt.errorMsg, err) } } }) } } func TestValidateAccess_NonRepositoryType(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" // Non-repository types should be ignored access := []AccessEntry{ { Type: "registry", Name: "something", Actions: []string{"admin"}, }, } err := ValidateAccess(userDID, userHandle, access) if err != nil { t.Errorf("Expected non-repository types to be ignored, got error: %v", err) } } func TestValidateAccess_EmptyAccess(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" err := ValidateAccess(userDID, userHandle, nil) if err != nil { t.Errorf("Expected no error for empty access, got: %v", err) } err = ValidateAccess(userDID, userHandle, []AccessEntry{}) if err != nil { t.Errorf("Expected no error for empty access slice, got: %v", err) } } func TestValidateAccess_InvalidRepositoryName(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" // Repository name without slash - invalid format access := []AccessEntry{ { Type: "repository", Name: "justareponame", Actions: []string{"push"}, }, } err := ValidateAccess(userDID, userHandle, access) if err != nil { // Should fail because can't extract owner from name without slash // and it's not "*", so it will try to access [0] which is the whole string // This is expected behavior - validate that owner check happens t.Logf("Got expected validation error: %v", err) } } func TestValidateAccess_DIDAndHandleBothWork(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" // Test with handle as owner accessByHandle := []AccessEntry{ { Type: "repository", Name: "alice.bsky.social/myapp", Actions: []string{"push"}, }, } err := ValidateAccess(userDID, userHandle, accessByHandle) if err != nil { t.Errorf("Expected no error for handle match, got: %v", err) } // Test with DID as owner accessByDID := []AccessEntry{ { Type: "repository", Name: "did:plc:alice123/myapp", Actions: []string{"push"}, }, } err = ValidateAccess(userDID, userHandle, accessByDID) if err != nil { t.Errorf("Expected no error for DID match, got: %v", err) } } func TestValidateAccess_MixedActionsAndOwnership(t *testing.T) { userDID := "did:plc:alice123" userHandle := "alice.bsky.social" // Mix of own and others' repositories access := []AccessEntry{ { Type: "repository", Name: "alice.bsky.social/myapp", Actions: []string{"push", "pull"}, }, { Type: "repository", Name: "bob.bsky.social/bobapp", Actions: []string{"pull"}, // OK - just pull }, } err := ValidateAccess(userDID, userHandle, access) if err != nil { t.Errorf("Expected no error for valid mixed access, got: %v", err) } // Now add push to someone else's repo - should fail access = []AccessEntry{ { Type: "repository", Name: "alice.bsky.social/myapp", Actions: []string{"push"}, }, { Type: "repository", Name: "bob.bsky.social/bobapp", Actions: []string{"push"}, // FAIL - can't push to others }, } err = ValidateAccess(userDID, userHandle, access) if err == nil { t.Error("Expected error when trying to push to others' repository") } } func TestParseScope_EmptyActionsArray(t *testing.T) { // Test with empty actions (colon present but no actions after it) access, err := ParseScope([]string{"repository:alice/myapp:"}) if err != nil { t.Fatalf("ParseScope() error = %v", err) } if len(access) != 1 { t.Fatalf("Expected 1 entry, got %d", len(access)) } // Actions should be nil or empty when actions string is empty if len(access[0].Actions) > 0 { t.Errorf("Expected nil or empty actions, got %v", access[0].Actions) } } func TestParseScope_NilInput(t *testing.T) { access, err := ParseScope(nil) if err != nil { t.Fatalf("ParseScope() with nil input error = %v", err) } if len(access) != 0 { t.Errorf("Expected empty access for nil input, got %d entries", len(access)) } }