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

368 lines
12 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"testing"
)
type fakeBrokerAuthorizer struct {
err error
calls int
sessionIDs []string
}
type fakeBrokerInstallationProvider struct {
resolved *ResolvedCredential
}
type blockingBrokerInstallationProvider struct {
started chan struct{}
release chan struct{}
resolved *ResolvedCredential
}
func (p *blockingBrokerInstallationProvider) AppCredentialGeneration(
registrationID string,
) (int64, bool) {
return p.resolved.AppCredentialGeneration,
p.resolved.AppRegistrationID == registrationID && p.resolved.AppCredentialGeneration > 0
}
func (p *blockingBrokerInstallationProvider) ResolveInstallation(
ctx context.Context,
_ *WorkspaceConnection,
_ ResolveCredentialRequest,
) (*ResolvedCredential, error) {
select {
case p.started <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
select {
case <-p.release:
return p.resolved, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (f fakeBrokerInstallationProvider) AppCredentialGeneration(registrationID string) (int64, bool) {
return f.resolved.AppCredentialGeneration,
f.resolved.AppRegistrationID == registrationID && f.resolved.AppCredentialGeneration > 0
}
func (f fakeBrokerInstallationProvider) ResolveInstallation(
_ context.Context,
_ *WorkspaceConnection,
_ ResolveCredentialRequest,
) (*ResolvedCredential, error) {
return f.resolved, nil
}
func (a *fakeBrokerAuthorizer) AuthorizeGitHubRepository(
_ context.Context,
_, _, sessionID, _, _, _ string,
) error {
a.calls++
a.sessionIDs = append(a.sessionIDs, sessionID)
return a.err
}
func newPATCredentialBroker(t *testing.T) (*CredentialBroker, *WorkspaceConnection, *fakeBrokerAuthorizer) {
t.Helper()
connection := &WorkspaceConnection{
WorkspaceID: "workspace-1",
Source: ConnectionSourcePAT,
Login: "octocat",
Status: ConnectionStatusActive,
CredentialGeneration: 7,
}
connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{
connection.WorkspaceID: connection,
}}
resolver := NewCredentialResolver(connections, fakeAuthSecrets{
WorkspacePATSecretKey(connection.WorkspaceID): "workspace-secret",
})
authorizer := &fakeBrokerAuthorizer{}
return NewCredentialBroker(connections, resolver, authorizer), connection, authorizer
}
func brokerLeaseRequest() CredentialLeaseRequest {
return CredentialLeaseRequest{
WorkspaceID: "workspace-1",
TaskID: "task-1",
SessionID: "session-1",
RepositoryID: "repository-1",
Owner: "kdlbs",
Repo: "kandev",
Host: "github.com",
}
}
func brokerCredentialRequest(lease string) BrokerCredentialRequest {
return BrokerCredentialRequest{
Lease: lease, TaskID: "task-1", SessionID: "session-1",
RepositoryID: "repository-1", Owner: "kdlbs", Repo: "kandev", Host: "github.com",
}
}
func TestCredentialBrokerStoresHashAndRenewsCredentialOnRedemption(t *testing.T) {
broker, _, authorizer := newPATCredentialBroker(t)
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatalf("Issue: %v", err)
}
if lease.Token == "" {
t.Fatal("empty lease")
}
if broker.ActiveLeaseCount() != 1 {
t.Fatalf("active leases = %d, want 1", broker.ActiveLeaseCount())
}
credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if credential.Password != "workspace-secret" || credential.Username != "x-access-token" {
t.Fatalf("credential = %+v", credential)
}
if authorizer.calls != 2 {
t.Fatalf("scope checks = %d, want 2", authorizer.calls)
}
if got := authorizer.sessionIDs; len(got) != 2 || got[0] != "session-1" || got[1] != "session-1" {
t.Fatalf("authorized sessions = %v, want session-1 twice", got)
}
}
func TestCredentialBrokerLimitsActiveLeasesPerWorkspace(t *testing.T) {
broker, _, _ := newPATCredentialBroker(t)
const maxExpectedLeases = 10_000
request := brokerLeaseRequest()
for i := range maxExpectedLeases {
request.TaskID = fmt.Sprintf("task-%d", i)
request.SessionID = fmt.Sprintf("session-%d", i)
if _, err := broker.Issue(context.Background(), request); err != nil {
t.Fatalf("Issue(%d): %v", i, err)
}
}
request.TaskID = "task-over-limit"
request.SessionID = "session-over-limit"
if _, err := broker.Issue(context.Background(), request); err == nil {
t.Fatal("Issue() succeeded after the workspace lease limit")
}
}
func TestCredentialBrokerRejectsScopeMismatch(t *testing.T) {
broker, _, _ := newPATCredentialBroker(t)
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
request := brokerCredentialRequest(lease.Token)
request.TaskID = "other-task"
_, err = broker.Resolve(context.Background(), request)
if !errors.Is(err, ErrCredentialScopeDenied) {
t.Fatalf("error = %v, want scope denied", err)
}
}
func TestCredentialBrokerGenerationChangeRevokesLease(t *testing.T) {
broker, connection, _ := newPATCredentialBroker(t)
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
connection.CredentialGeneration++
_, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if !errors.Is(err, ErrCredentialLeaseRevoked) {
t.Fatalf("error = %v, want revoked", err)
}
}
func TestCredentialBrokerAppRegistrationChangeRevokesLease(t *testing.T) {
installationID := int64(42)
connection := &WorkspaceConnection{
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
InstallationID: &installationID, AppRegistrationID: "registration-a",
Status: ConnectionStatusActive, CredentialGeneration: 7,
}
connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{
connection.WorkspaceID: connection,
}}
resolver := NewCredentialResolver(connections, nil)
resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{
Client: &MockClient{}, Principal: AuthPrincipal{
Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation,
AppRegistrationID: "registration-a", AppCredentialGeneration: 3,
},
Capabilities: map[GitHubAppCapability]bool{CapabilityGitRead: true},
AppRegistrationID: "registration-a", AppCredentialGeneration: 3,
credential: "installation-token",
}})
broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{})
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
connection.AppRegistrationID = "registration-b"
_, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if !errors.Is(err, ErrCredentialLeaseRevoked) {
t.Fatalf("error = %v, want revoked", err)
}
}
func TestCredentialBrokerDoesNotReturnCredentialAfterConcurrentAppSwitch(t *testing.T) {
installationID := int64(42)
connection := WorkspaceConnection{
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
InstallationID: &installationID, AppRegistrationID: "registration-a",
Status: ConnectionStatusActive, CredentialGeneration: 7,
}
connections := &synchronizedConnectionReader{connection: connection}
provider := &blockingBrokerInstallationProvider{
started: make(chan struct{}), release: make(chan struct{}),
resolved: &ResolvedCredential{
Client: &MockClient{}, Principal: AuthPrincipal{
Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation,
AppRegistrationID: "registration-a", AppCredentialGeneration: 3,
},
Capabilities: map[GitHubAppCapability]bool{CapabilityGitRead: true},
CredentialGeneration: 7, AppRegistrationID: "registration-a",
AppCredentialGeneration: 3, credential: "old-installation-token",
},
}
resolver := NewCredentialResolver(connections, nil)
resolver.SetInstallationProvider(provider)
broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{})
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
result := make(chan *BrokerCredential, 1)
errs := make(chan error, 1)
go func() {
credential, resolveErr := broker.Resolve(
context.Background(), brokerCredentialRequest(lease.Token),
)
result <- credential
errs <- resolveErr
}()
<-provider.started
replacement := connection
replacement.AppRegistrationID = "registration-b"
replacement.CredentialGeneration++
connections.replace(replacement)
close(provider.release)
if credential, resolveErr := <-result, <-errs; credential != nil ||
!errors.Is(resolveErr, ErrCredentialLeaseRevoked) {
t.Fatalf("Resolve() = %+v, %v; want nil, ErrCredentialLeaseRevoked", credential, resolveErr)
}
}
func TestCredentialBrokerExpiryAndExplicitRevocation(t *testing.T) {
broker, _, _ := newPATCredentialBroker(t)
req := brokerLeaseRequest()
lease, err := broker.Issue(context.Background(), req)
if err != nil {
t.Fatal(err)
}
broker.RevokeTask("task-1")
_, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if !errors.Is(err, ErrCredentialLeaseInvalid) {
t.Fatalf("error = %v, want invalid after revoke", err)
}
}
func TestCredentialBrokerNeverReturnsPersonalCredential(t *testing.T) {
broker, _, _ := newPATCredentialBroker(t)
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if err != nil {
t.Fatal(err)
}
if credential.Principal.UserID != "" || credential.Principal.Source != ConnectionSourcePAT {
t.Fatalf("principal = %+v", credential.Principal)
}
}
func TestCredentialBrokerAllowsReadOnlyAppForGitTransport(t *testing.T) {
connection := &WorkspaceConnection{
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
AppRegistrationID: "registration-a",
Status: ConnectionStatusActive, CredentialGeneration: 7,
}
connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{
connection.WorkspaceID: connection,
}}
resolver := NewCredentialResolver(connections, nil)
resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{
Client: &MockClient{}, Principal: AuthPrincipal{
Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation,
WorkspaceID: connection.WorkspaceID, AppRegistrationID: "registration-a",
AppCredentialGeneration: 3,
},
Capabilities: map[GitHubAppCapability]bool{
CapabilityGitRead: true,
},
CredentialGeneration: 7,
AppRegistrationID: "registration-a",
AppCredentialGeneration: 3,
credential: "installation-token",
}})
broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{})
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
credential, err := broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if credential.Password != "installation-token" {
t.Fatalf("credential password = %q", credential.Password)
}
}
func TestCredentialBrokerRejectsAppWithoutGitRead(t *testing.T) {
connection := &WorkspaceConnection{
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
AppRegistrationID: "registration-a",
Status: ConnectionStatusActive, CredentialGeneration: 7,
}
connections := &fakeConnectionReader{workspaces: map[string]*WorkspaceConnection{
connection.WorkspaceID: connection,
}}
resolver := NewCredentialResolver(connections, nil)
resolver.SetInstallationProvider(fakeBrokerInstallationProvider{resolved: &ResolvedCredential{
Client: &MockClient{}, Principal: AuthPrincipal{
Kind: AuthPrincipalApp, Source: ConnectionSourceGitHubAppInstallation,
WorkspaceID: connection.WorkspaceID, AppRegistrationID: "registration-a",
AppCredentialGeneration: 3,
},
Capabilities: map[GitHubAppCapability]bool{},
CredentialGeneration: 7,
AppRegistrationID: "registration-a",
AppCredentialGeneration: 3,
credential: "installation-token",
}})
broker := NewCredentialBroker(connections, resolver, &fakeBrokerAuthorizer{})
lease, err := broker.Issue(context.Background(), brokerLeaseRequest())
if err != nil {
t.Fatal(err)
}
_, err = broker.Resolve(context.Background(), brokerCredentialRequest(lease.Token))
if !errors.Is(err, ErrGitHubCapabilityDenied) {
t.Fatalf("Resolve() error = %v, want capability denied", err)
}
}