Files
kandev/apps/backend/internal/github/service_token_test.go
T
João Salavisa 75f1732421 feat: add GitHub token injection for remote executors and Docker session resume (#654)
* feat: add GitHub token injection for remote executors and Docker session resume

- Add global GitHub access token configuration (backend + frontend)
- Inject GitHub token into Docker and Sprites executors via env vars and Git credential helper
- Transition Docker executor from host mounting to repository cloning
- Implement Docker session resume (reconnect to existing containers after backend restart)
- Add shell validation using exec.LookPath in agentctl
- Add scriptengine providers for agent install and agentctl placeholders

* fix: format github-status.tsx with prettier

* fix: resolve lint issues (goconst, errcheck, file-length)

* fix: address code review findings

- Fix resolveGHCLIToken to check both GITHUB_TOKEN and GH_TOKEN guards
- Fix credential helper to use \\ fallback
- Change /bin/bash to /bin/sh in credential helper for Alpine compat
- Strip embedded token from .git/config after clone in prepare scripts
- Extract buildLaunchAgentRequest helpers to stay under 80-line limit
- Add tests for GitHubAuthProvider, injectTokenIntoURL, reconnect validation
- Add GH_TOKEN-only test case for resolveGHCLIToken

* refactor(executor): extract GitHub credential resolution to separate file

Move injectGitHubToken, injectGitHubTokenFromCLI, resolveRemoteCredentials,
resolveAuthSecrets, methodIDToEnvVar, resolveGHCLIToken, and detectGHToken
out of executor_execute.go (was 803 logical lines, over the 800-line limit
after merging main) into a new executor_credentials.go file. Also adds the
nil-guard on secretStore in resolveAuthSecrets that was flagged by review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 22:15:50 +01:00

265 lines
6.6 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")
}
}