Files
kandev/apps/backend/internal/github/service_connections_test.go
T

505 lines
20 KiB
Go

package github
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/stretchr/testify/require"
)
type fakeConnectionSecrets struct {
values map[string]string
setErr error
setErrKey string
deleteErr error
deleteErrKey string
deleteThenError bool
}
func newFakeConnectionSecrets() *fakeConnectionSecrets {
return &fakeConnectionSecrets{values: make(map[string]string)}
}
func (f *fakeConnectionSecrets) Reveal(_ context.Context, id string) (string, error) {
value, ok := f.values[id]
if !ok {
return "", errors.New("secret not found")
}
return value, nil
}
func (f *fakeConnectionSecrets) Set(_ context.Context, id, _ string, value string) error {
if f.setErr != nil && (f.setErrKey == "" || f.setErrKey == id) {
return f.setErr
}
f.values[id] = value
return nil
}
func (f *fakeConnectionSecrets) Delete(_ context.Context, id string) error {
if f.deleteErr != nil && (f.deleteErrKey == "" || f.deleteErrKey == id) {
if f.deleteThenError {
delete(f.values, id)
}
return f.deleteErr
}
delete(f.values, id)
return nil
}
func (f *fakeConnectionSecrets) Exists(_ context.Context, id string) (bool, error) {
_, ok := f.values[id]
return ok, nil
}
func (f *fakeConnectionSecrets) ListIDs(context.Context) ([]string, error) {
ids := make([]string, 0, len(f.values))
for id := range f.values {
ids = append(ids, id)
}
return ids, nil
}
func newWorkspaceConnectionService(t *testing.T, login string) (*Service, *fakeConnectionSecrets) {
t.Helper()
store := newTestStore(t)
if _, err := store.db.Exec(`CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY)`); err != nil {
t.Fatalf("create workspaces table: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO workspaces (id) VALUES ('ws-1')`); err != nil {
t.Fatalf("seed workspace: %v", err)
}
service := NewService(nil, AuthMethodNone, nil, store, nil, testLogger(t))
secrets := newFakeConnectionSecrets()
service.SetConnectionSecretStore(secrets)
if err := store.UpsertDeploymentAppRegistration(context.Background(),
newAppRegistration("registration-test", 101, "Test App", time.Now().UTC())); err != nil {
t.Fatalf("seed App registration: %v", err)
}
service.tokenClientFactory = func(string) Client {
client := NewMockClient()
client.SetUser(login)
return client
}
return service, secrets
}
func TestSetWorkspaceConnectionPATIsScopedAndValidatedBeforeWrite(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
connection, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT,
Token: "ghp_workspace",
})
if err != nil {
t.Fatalf("SetWorkspaceConnection: %v", err)
}
if connection.Login != "octocat" || connection.CredentialGeneration != 1 {
t.Fatalf("connection = %+v", connection)
}
if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "ghp_workspace" {
t.Fatalf("stored PAT = %q", got)
}
}
func TestWorkspaceAuthStatusIncludesEffectiveActorsAndCapabilities(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "octocat")
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT,
Token: "ghp_workspace",
}); err != nil {
t.Fatalf("SetWorkspaceConnection: %v", err)
}
status, err := service.GetWorkspaceAuthStatus(context.Background(), "ws-1", DefaultUserID)
if err != nil {
t.Fatalf("GetWorkspaceAuthStatus: %v", err)
}
if !status.Authenticated || status.AuthMethod != string(ConnectionSourcePAT) ||
!status.TokenConfigured || status.Username != "octocat" {
t.Fatalf("unexpected compatibility status: %+v", status)
}
if status.Automation == nil || status.Automation.Actor == nil ||
status.Automation.Actor.Login != "octocat" || status.Automation.Actor.Kind != AuthPrincipalHuman {
t.Fatalf("unexpected automation actor: %+v", status.Automation)
}
if len(status.Automation.MissingCapabilities) != 0 ||
!status.Automation.Capabilities[CapabilityPullRequestWrite] {
t.Fatalf("unexpected capabilities: %+v", status.Automation)
}
if status.EffectivePersonalActor == nil || status.EffectivePersonalActor.Login != "octocat" ||
status.EffectiveManualMutationActor == nil || status.EffectiveManualMutationActor.Login != "octocat" {
t.Fatalf("unexpected effective actors: personal=%+v mutation=%+v",
status.EffectivePersonalActor, status.EffectiveManualMutationActor)
}
}
func TestSetWorkspaceConnectionCLIStoresSelectionNotToken(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "work-user")
service.resolver.ghToken = func(_ context.Context, host, login string) (string, error) {
if host != "github.com" || login != "work-user" {
t.Fatalf("selected account = %s@%s", login, host)
}
return "cli-derived-token", nil
}
connection, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourceGHCLI,
Host: "github.com",
Login: "work-user",
})
if err != nil {
t.Fatalf("SetWorkspaceConnection: %v", err)
}
if connection.Source != ConnectionSourceGHCLI || connection.Login != "work-user" {
t.Fatalf("connection = %+v", connection)
}
if len(secrets.values) != 0 {
t.Fatalf("CLI token was persisted: %#v", secrets.values)
}
}
func TestSetWorkspaceConnectionCLIDeniesMembersButAllowsAdminAndSyntheticIdentity(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "work-user")
service.resolver.ghToken = func(_ context.Context, _, _ string) (string, error) {
return "cli-derived-token", nil
}
request := SetWorkspaceConnectionRequest{
Source: ConnectionSourceGHCLI, Host: "github.com", Login: "work-user",
}
memberContext := authn.WithIdentity(context.Background(), authn.Identity{
UserID: "member-1", Role: authn.RoleMember,
})
if _, err := service.SetWorkspaceConnection(memberContext, "ws-1", request); !errors.Is(err, ErrGHCLIOperatorRequired) {
t.Fatalf("member error = %v, want operator denial", err)
}
adminContext := authn.WithIdentity(context.Background(), authn.Identity{
UserID: "admin-1", Role: authn.RoleAdmin,
})
if _, err := service.SetWorkspaceConnection(adminContext, "ws-1", request); err != nil {
t.Fatalf("admin SetWorkspaceConnection() error = %v", err)
}
syntheticContext := authn.WithIdentity(context.Background(), authn.Identity{
UserID: DefaultUserID, Role: authn.RoleAdmin, Synthetic: true,
})
if _, err := service.SetWorkspaceConnection(syntheticContext, "ws-1", request); err != nil {
t.Fatalf("synthetic SetWorkspaceConnection() error = %v", err)
}
}
func TestSetWorkspaceConnectionValidationFailurePreservesPreviousPAT(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "first",
}); err != nil {
t.Fatal(err)
}
service.tokenClientFactory = func(string) Client { return &NoopClient{} }
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "invalid",
}); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("error = %v, want ErrInvalidToken", err)
}
if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "first" {
t.Fatalf("previous PAT changed to %q", got)
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil || connection.CredentialGeneration != 1 {
t.Fatalf("connection after failure = %+v, err %v", connection, err)
}
}
func TestDeleteWorkspaceConnectionRemovesOwnedPAT(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "token",
}); err != nil {
t.Fatal(err)
}
if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err != nil {
t.Fatalf("DeleteWorkspaceConnection: %v", err)
}
if _, ok := secrets.values[WorkspacePATSecretKey("ws-1")]; ok {
t.Fatal("workspace PAT remains after disconnect")
}
}
func TestDeleteWorkspaceConnectionPreservesPATConnectionWhenSecretDeleteFails(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "token",
}); err != nil {
t.Fatal(err)
}
secrets.deleteErr = errors.New("secret store unavailable")
secrets.deleteErrKey = WorkspacePATSecretKey("ws-1")
if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err == nil {
t.Fatal("expected disconnect failure")
}
connection, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil || connection == nil || connection.Source != ConnectionSourcePAT {
t.Fatalf("connection after failed disconnect = %+v, err %v", connection, err)
}
if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "token" {
t.Fatalf("PAT after failed disconnect = %q", got)
}
}
func TestSetWorkspaceConnectionPATRevokesPersonalAppConnections(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
installationID := int64(42)
if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
personal := &UserConnection{
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test",
GitHubUserID: 7, Login: "human",
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
}
if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{
AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt,
}, 0); err != nil {
t.Fatal(err)
}
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "workspace-token",
}); err != nil {
t.Fatal(err)
}
stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1")
if err != nil || stored != nil {
t.Fatalf("personal connection after App replacement = %+v, err %v", stored, err)
}
if _, ok := secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")]; ok {
t.Fatal("personal access secret remains after App replacement")
}
if _, ok := secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")]; ok {
t.Fatal("personal refresh secret remains after App replacement")
}
}
func TestDeleteWorkspaceAppConnectionRevokesPersonalConnections(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
installationID := int64(42)
if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
personal := &UserConnection{
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test",
GitHubUserID: 7, Login: "human",
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
}
if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{
AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt,
}, 0); err != nil {
t.Fatal(err)
}
if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err != nil {
t.Fatal(err)
}
stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1")
if err != nil || stored != nil || len(secrets.values) != 0 {
t.Fatalf("personal state after App disconnect = %+v, secrets=%#v, err %v", stored, secrets.values, err)
}
}
func TestFailedWorkspaceAppTransitionRestoresPersonalConnection(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
ctx := context.Background()
installationID := int64(42)
if err := service.store.UpsertWorkspaceConnection(ctx, activeAppWorkspace("ws-1", installationID)); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
personal := &UserConnection{
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test",
GitHubUserID: 7, Login: "human", Status: ConnectionStatusActive,
AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
}
if err := service.personalConnections.ReplacePersonalConnection(ctx, personal, GitHubOAuthTokens{
AccessToken: "personal-access", RefreshToken: "personal-refresh",
AccessExpiresAt: personal.AccessExpiresAt,
}, 0); err != nil {
t.Fatal(err)
}
err := (&serviceAppConnectionStore{service: service}).UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
WorkspaceID: "ws-1", Source: ConnectionSourcePAT, GitHubHost: defaultGitHubHost,
Status: ConnectionStatusActive,
})
if err == nil {
t.Fatal("invalid workspace transition unexpectedly succeeded")
}
workspace, getErr := service.store.GetWorkspaceConnection(ctx, "ws-1")
if getErr != nil || workspace == nil || workspace.AppRegistrationID != "registration-test" {
t.Fatalf("workspace after failed transition = %+v, err %v", workspace, getErr)
}
stored, getErr := service.store.GetUserConnection(ctx, "ws-1", "user-1")
if getErr != nil || stored == nil || stored.AppRegistrationID != "registration-test" {
t.Fatalf("personal connection after failed transition = %+v, err %v", stored, getErr)
}
if secrets.values[UserAccessTokenSecretKey("ws-1", "user-1")] != "personal-access" ||
secrets.values[UserRefreshTokenSecretKey("ws-1", "user-1")] != "personal-refresh" {
t.Fatalf("personal secrets after failed transition = %#v", secrets.values)
}
}
func TestDeleteWorkspaceAppConnectionCompensatesPersonalRevokeFailure(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
installationID := int64(42)
if err := service.store.UpsertWorkspaceConnection(context.Background(), activeAppWorkspace("ws-1", installationID)); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
personal := &UserConnection{
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test",
GitHubUserID: 7, Login: "human",
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
}
if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{
AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt,
}, 0); err != nil {
t.Fatal(err)
}
secrets.deleteErr = errors.New("personal secret delete failed")
secrets.deleteErrKey = UserRefreshTokenSecretKey("ws-1", "user-1")
if err := service.DeleteWorkspaceConnection(context.Background(), "ws-1"); err == nil {
t.Fatal("expected disconnect failure")
}
workspace, err := service.store.GetWorkspaceConnection(context.Background(), "ws-1")
if err != nil || workspace == nil || workspace.InstallationID == nil || *workspace.InstallationID != installationID {
t.Fatalf("workspace connection after compensation = %+v, err %v", workspace, err)
}
stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1")
if err != nil || stored == nil {
t.Fatalf("personal connection after compensation = %+v, err %v", stored, err)
}
}
func TestDifferentAppInstallationRequiresPersonalConnectionRevocation(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "octocat")
oldInstallationID, newInstallationID := int64(42), int64(84)
existing := activeAppWorkspace("ws-1", oldInstallationID)
if err := service.store.UpsertWorkspaceConnection(context.Background(), existing); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
personal := &UserConnection{
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-test",
GitHubUserID: 7, Login: "human",
Status: ConnectionStatusActive, AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
}
if err := service.personalConnections.ReplacePersonalConnection(context.Background(), personal, GitHubOAuthTokens{
AccessToken: "personal-access", RefreshToken: "personal-refresh", AccessExpiresAt: personal.AccessExpiresAt,
}, 0); err != nil {
t.Fatal(err)
}
replacement := activeAppWorkspace("ws-1", newInstallationID)
if err := service.revokePersonalForAutomationTransition(context.Background(), existing, replacement); err != nil {
t.Fatal(err)
}
stored, err := service.store.GetUserConnection(context.Background(), "ws-1", "user-1")
if err != nil || stored != nil {
t.Fatalf("personal connection after installation replacement = %+v, err %v", stored, err)
}
}
func TestSetWorkspaceConnectionRejectsStaleValidatedMutation(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "octocat")
started := make(chan struct{})
release := make(chan struct{})
service.tokenClientFactory = func(token string) Client {
if token == "slow" {
close(started)
<-release
}
client := NewMockClient()
client.SetUser("octocat")
return client
}
slowResult := make(chan error, 1)
go func() {
_, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "slow",
})
slowResult <- err
}()
<-started
if _, err := service.SetWorkspaceConnection(context.Background(), "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT, Token: "newer",
}); err != nil {
t.Fatal(err)
}
close(release)
if err := <-slowResult; !errors.Is(err, ErrWorkspaceConnectionStale) {
t.Fatalf("slow mutation error = %v, want stale", err)
}
if got := secrets.values[WorkspacePATSecretKey("ws-1")]; got != "newer" {
t.Fatalf("PAT after stale mutation = %q", got)
}
}
func TestWorkspaceConnectionStaleUsesConflictResponse(t *testing.T) {
status, code := githubAuthErrorResponse(ErrWorkspaceConnectionStale)
if status != http.StatusConflict || code != "github_connection_changed" {
t.Fatalf("stale response = %d, %q", status, code)
}
}
func TestCopyWorkspaceConnectionToWorkspace(t *testing.T) {
t.Run("copies PAT connection and secret", func(t *testing.T) {
service, secrets := newWorkspaceConnectionService(t, "alice")
ctx := context.Background()
if _, err := service.store.db.Exec(`INSERT INTO workspaces (id) VALUES ('ws-dst')`); err != nil {
t.Fatalf("seed destination workspace: %v", err)
}
if _, err := service.SetWorkspaceConnection(ctx, "ws-1", SetWorkspaceConnectionRequest{
Source: ConnectionSourcePAT,
Login: "alice",
Token: "ghp_source_token",
}); err != nil {
t.Fatalf("seed source connection: %v", err)
}
require.NoError(t, service.CopyWorkspaceConnectionToWorkspace(ctx, "ws-1", "ws-dst"))
got, err := service.store.GetWorkspaceConnection(ctx, "ws-dst")
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, ConnectionSourcePAT, got.Source)
require.Equal(t, "alice", got.Login)
require.Equal(t, ConnectionStatusActive, got.Status)
token, err := secrets.Reveal(ctx, WorkspacePATSecretKey("ws-dst"))
require.NoError(t, err)
require.Equal(t, "ghp_source_token", token)
})
t.Run("no-op without a source connection", func(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "alice")
ctx := context.Background()
if _, err := service.store.db.Exec(`INSERT INTO workspaces (id) VALUES ('ws-dst')`); err != nil {
t.Fatalf("seed destination workspace: %v", err)
}
require.NoError(t, service.CopyWorkspaceConnectionToWorkspace(ctx, "ws-1", "ws-dst"))
got, err := service.store.GetWorkspaceConnection(ctx, "ws-dst")
require.NoError(t, err)
require.Nil(t, got)
})
t.Run("no-op for identical workspaces", func(t *testing.T) {
service, _ := newWorkspaceConnectionService(t, "alice")
require.NoError(t, service.CopyWorkspaceConnectionToWorkspace(context.Background(), "ws-1", "ws-1"))
})
}