486 lines
12 KiB
Go
486 lines
12 KiB
Go
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))
|
|
}
|
|
}
|