285 lines
7.3 KiB
Go
285 lines
7.3 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// mockSecretManager implements SecretManager for testing.
|
|
type mockSecretManager struct {
|
|
secrets map[string]string // id -> value
|
|
names map[string]string // id -> name
|
|
nextID int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func newMockSecretManager() *mockSecretManager {
|
|
return &mockSecretManager{
|
|
secrets: make(map[string]string),
|
|
names: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
func (m *mockSecretManager) Create(_ context.Context, name, value string) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.nextID++
|
|
id := fmt.Sprintf("secret-%d", m.nextID)
|
|
m.secrets[id] = value
|
|
m.names[id] = name
|
|
return id, nil
|
|
}
|
|
|
|
func (m *mockSecretManager) Update(_ context.Context, id, value string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.secrets[id]; !ok {
|
|
return fmt.Errorf("secret not found: %s", id)
|
|
}
|
|
m.secrets[id] = value
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSecretManager) Delete(_ context.Context, id string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.secrets, id)
|
|
delete(m.names, id)
|
|
return nil
|
|
}
|
|
|
|
// mockSecretProvider implements SecretProvider for testing.
|
|
type mockSecretProvider struct {
|
|
mgr *mockSecretManager
|
|
}
|
|
|
|
func (m *mockSecretProvider) List(_ context.Context) ([]*SecretListItem, error) {
|
|
m.mgr.mu.Lock()
|
|
defer m.mgr.mu.Unlock()
|
|
var items []*SecretListItem
|
|
for id, name := range m.mgr.names {
|
|
items = append(items, &SecretListItem{
|
|
ID: id,
|
|
Name: name,
|
|
HasValue: m.mgr.secrets[id] != "",
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (m *mockSecretProvider) Reveal(_ context.Context, id string) (string, error) {
|
|
m.mgr.mu.Lock()
|
|
defer m.mgr.mu.Unlock()
|
|
if val, ok := m.mgr.secrets[id]; ok {
|
|
return val, nil
|
|
}
|
|
return "", fmt.Errorf("secret not found: %s", id)
|
|
}
|
|
|
|
func TestFindGitHubTokenSecret(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
}
|
|
|
|
// No secrets - should return empty
|
|
id, found := svc.findGitHubTokenSecret(context.Background())
|
|
if found {
|
|
t.Error("expected no token to be found when secrets are empty")
|
|
}
|
|
if id != "" {
|
|
t.Errorf("expected empty id, got %q", id)
|
|
}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
secretID, _ := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_test123")
|
|
|
|
id, found = svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Error("expected token to be found")
|
|
}
|
|
if id != secretID {
|
|
t.Errorf("expected id %q, got %q", secretID, id)
|
|
}
|
|
}
|
|
|
|
func TestFindGitHubTokenSecret_LowerCase(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
}
|
|
|
|
// Add a github_token (lowercase) secret
|
|
secretID, _ := mgr.Create(context.Background(), "github_token", "ghp_test456")
|
|
|
|
id, found := svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Error("expected token to be found with lowercase name")
|
|
}
|
|
if id != secretID {
|
|
t.Errorf("expected id %q, got %q", secretID, id)
|
|
}
|
|
}
|
|
|
|
func TestGetStatus_WithTokenConfigured(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
secretID, _ := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_test789")
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
authMethod: AuthMethodNone,
|
|
}
|
|
|
|
status, err := svc.GetStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatus failed: %v", err)
|
|
}
|
|
|
|
if !status.TokenConfigured {
|
|
t.Error("expected TokenConfigured to be true")
|
|
}
|
|
if status.TokenSecretID != secretID {
|
|
t.Errorf("expected TokenSecretID %q, got %q", secretID, status.TokenSecretID)
|
|
}
|
|
if len(status.RequiredScopes) == 0 {
|
|
t.Error("expected RequiredScopes to be populated")
|
|
}
|
|
}
|
|
|
|
func TestGetStatus_WithoutToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
logger: log,
|
|
authMethod: AuthMethodNone,
|
|
}
|
|
|
|
status, err := svc.GetStatus(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetStatus failed: %v", err)
|
|
}
|
|
|
|
if status.TokenConfigured {
|
|
t.Error("expected TokenConfigured to be false")
|
|
}
|
|
if status.TokenSecretID != "" {
|
|
t.Errorf("expected empty TokenSecretID, got %q", status.TokenSecretID)
|
|
}
|
|
}
|
|
|
|
func TestClearToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
// Add a GITHUB_TOKEN secret
|
|
_, err := mgr.Create(context.Background(), "GITHUB_TOKEN", "ghp_toberemoved")
|
|
if err != nil {
|
|
t.Fatalf("failed to create secret: %v", err)
|
|
}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
secretManager: mgr,
|
|
logger: log,
|
|
authMethod: AuthMethodPAT,
|
|
}
|
|
|
|
// Verify token exists
|
|
_, found := svc.findGitHubTokenSecret(context.Background())
|
|
if !found {
|
|
t.Fatal("expected token to exist before clearing")
|
|
}
|
|
|
|
// Clear the token
|
|
err = svc.ClearToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ClearToken failed: %v", err)
|
|
}
|
|
|
|
// Verify token is gone
|
|
_, found = svc.findGitHubTokenSecret(context.Background())
|
|
if found {
|
|
t.Error("expected token to be removed after clearing")
|
|
}
|
|
|
|
// After ClearToken, authMethod should no longer be "pat" (it resets to "none"
|
|
// then retryClientCreation may set it to "gh_cli" if gh CLI is available).
|
|
if svc.authMethod == AuthMethodPAT {
|
|
t.Error("expected authMethod to no longer be PAT after clearing token")
|
|
}
|
|
}
|
|
|
|
func TestClearToken_NoToken(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
mgr := newMockSecretManager()
|
|
provider := &mockSecretProvider{mgr: mgr}
|
|
|
|
svc := &Service{
|
|
secrets: provider,
|
|
secretManager: mgr,
|
|
logger: log,
|
|
}
|
|
|
|
// Clearing when no token exists should not error
|
|
err := svc.ClearToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ClearToken should not error when no token exists: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClearToken_NoSecretManager(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
|
|
svc := &Service{
|
|
secrets: nil,
|
|
secretManager: nil,
|
|
logger: log,
|
|
}
|
|
|
|
err := svc.ClearToken(context.Background())
|
|
if err == nil {
|
|
t.Error("expected error when secretManager is nil")
|
|
}
|
|
}
|
|
|
|
// Regression: PAT clients minted via the service must share the rate tracker.
|
|
// ConfigureToken previously created a bare NewPATClient and dropped quota
|
|
// signals on the floor, so the UI / health / poller throttling all went stale
|
|
// after a token reconfigure.
|
|
func TestService_NewPATClient_AttachesRateTracker(t *testing.T) {
|
|
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
|
|
svc := &Service{
|
|
logger: log,
|
|
rateTracker: NewRateTracker(nil, nil),
|
|
}
|
|
|
|
c := svc.newPATClient("ghp_x")
|
|
if c == nil {
|
|
t.Fatalf("newPATClient returned nil")
|
|
}
|
|
if c.rateTracker != svc.rateTracker {
|
|
t.Fatalf("expected rate tracker to be wired onto the new PAT client")
|
|
}
|
|
}
|