635 lines
26 KiB
Go
635 lines
26 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAppRegistrationSchemaUsesCatalogInsteadOfSingleton(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
for _, table := range []string{
|
|
"github_app_registrations",
|
|
"github_app_registration_flows",
|
|
} {
|
|
var count int
|
|
if err := store.ro.GetContext(ctx, &count,
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table); err != nil {
|
|
t.Fatalf("read table %s: %v", table, err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("table %s count = %d, want 1", table, count)
|
|
}
|
|
}
|
|
|
|
for _, table := range []string{
|
|
"github_app_registration",
|
|
"github_app_registration_flow_head",
|
|
} {
|
|
var count int
|
|
if err := store.ro.GetContext(ctx, &count,
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table); err != nil {
|
|
t.Fatalf("read table %s: %v", table, err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("unpublished singleton table %s still exists", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationStorePersistsMultipleRegistrations(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
registrations := []*AppRegistration{
|
|
newAppRegistration("registration-work", 101, "Work", now),
|
|
newAppRegistration("registration-personal", 202, "Personal", now),
|
|
}
|
|
for _, registration := range registrations {
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatalf("save registration %s: %v", registration.ID, err)
|
|
}
|
|
}
|
|
|
|
for _, want := range registrations {
|
|
var got AppRegistration
|
|
err := store.ro.GetContext(ctx, &got, `
|
|
SELECT id, source, display_name, github_host, app_id, client_id, slug, owner_login,
|
|
owner_type, visibility, public_base_url, COALESCE(created_for_workspace_id, '') AS created_for_workspace_id,
|
|
credential_generation, credential_secret_id, status, webhook_status, last_webhook_at,
|
|
COALESCE(last_error, '') AS last_error, created_at, updated_at
|
|
FROM github_app_registrations WHERE id = ?`, want.ID)
|
|
if err != nil {
|
|
t.Fatalf("get registration %s: %v", want.ID, err)
|
|
}
|
|
if got.ID != want.ID || got.AppID != want.AppID || got.DisplayName != want.DisplayName {
|
|
t.Fatalf("registration %s = %+v, want %+v", want.ID, got, want)
|
|
}
|
|
}
|
|
|
|
var got []*AppRegistration
|
|
if err := store.ro.SelectContext(ctx, &got, `
|
|
SELECT id, source, display_name, github_host, app_id, client_id, slug, owner_login,
|
|
owner_type, visibility, public_base_url, COALESCE(created_for_workspace_id, '') AS created_for_workspace_id,
|
|
credential_generation, credential_secret_id, status, webhook_status, last_webhook_at,
|
|
COALESCE(last_error, '') AS last_error, created_at, updated_at
|
|
FROM github_app_registrations ORDER BY id`); err != nil {
|
|
t.Fatalf("list registrations: %v", err)
|
|
}
|
|
if len(got) != 2 || got[0].ID != "registration-personal" || got[1].ID != "registration-work" {
|
|
t.Fatalf("registrations = %+v", got)
|
|
}
|
|
}
|
|
|
|
func newAppRegistration(id string, appID int64, displayName string, now time.Time) *AppRegistration {
|
|
return &AppRegistration{
|
|
ID: id, Source: AppRegistrationSourceManaged, DisplayName: displayName,
|
|
GitHubHost: "github.com", AppID: appID, ClientID: "Iv1.client", Slug: "kandev-" + id,
|
|
OwnerLogin: "acme", OwnerType: AppRegistrationOwnerOrganization,
|
|
Visibility: AppRegistrationVisibilityPrivate, PublicBaseURL: "https://kandev.example.com",
|
|
CredentialGeneration: 1, CredentialSecretID: "github:app-registration:" + id + ":g1:test",
|
|
Status: AppRegistrationStatusActive, WebhookStatus: DeploymentAppWebhookUnverified,
|
|
CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationReferencesRoundTrip(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
registration := newAppRegistration("registration-1", 101, "Work", now)
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: "github.com", InstallationID: &installationID,
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
AppRegistrationID: registration.ID, Status: ConnectionStatusActive,
|
|
}); err != nil {
|
|
t.Fatalf("save workspace registration reference: %v", err)
|
|
}
|
|
workspace, err := store.GetWorkspaceConnection(ctx, "ws-1")
|
|
if err != nil || workspace == nil || workspace.AppRegistrationID != registration.ID {
|
|
t.Fatalf("workspace registration reference = %+v, err %v", workspace, err)
|
|
}
|
|
|
|
if err := store.UpsertUserConnection(ctx, &UserConnection{
|
|
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: registration.ID,
|
|
GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive,
|
|
AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatalf("save personal registration reference: %v", err)
|
|
}
|
|
personal, err := store.GetUserConnection(ctx, "ws-1", "user-1")
|
|
if err != nil || personal == nil || personal.AppRegistrationID != registration.ID {
|
|
t.Fatalf("personal registration reference = %+v, err %v", personal, err)
|
|
}
|
|
|
|
flow := &AuthFlow{
|
|
StateHash: "state", WorkspaceID: "ws-1", UserID: "user-1",
|
|
AppRegistrationID: registration.ID, Kind: AuthFlowKindPersonal,
|
|
ExpiresAt: now.Add(time.Hour),
|
|
}
|
|
if err := store.CreateAuthFlow(ctx, flow); err != nil {
|
|
t.Fatalf("save auth flow registration reference: %v", err)
|
|
}
|
|
storedFlow, err := store.GetAuthFlow(ctx, flow.StateHash)
|
|
if err != nil || storedFlow == nil || storedFlow.AppRegistrationID != registration.ID {
|
|
t.Fatalf("auth flow registration reference = %+v, err %v", storedFlow, err)
|
|
}
|
|
}
|
|
|
|
func TestPersonalConnectionRejectsDifferentWorkspaceRegistration(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
work := newAppRegistration("registration-work", 101, "Work", now)
|
|
personal := newAppRegistration("registration-personal", 202, "Personal", now)
|
|
for _, registration := range []*AppRegistration{work, personal} {
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: "github.com", InstallationID: &installationID,
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
AppRegistrationID: work.ID, Status: ConnectionStatusActive,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := store.UpsertUserConnection(ctx, &UserConnection{
|
|
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: personal.ID,
|
|
GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive,
|
|
AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
|
|
})
|
|
if !errors.Is(err, ErrOAuthFlowStale) {
|
|
t.Fatalf("mismatched personal registration error = %v, want %v", err, ErrOAuthFlowStale)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationRepositoryKeepsCredentialBundlesIndependent(t *testing.T) {
|
|
store := newTestStore(t)
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
work := newAppRegistration("registration-work", 101, "Work", now)
|
|
personal := newAppRegistration("registration-personal", 202, "Personal", now)
|
|
workCredentials := DeploymentAppCredentials{
|
|
PrivateKey: "work-key", ClientSecret: "work-client", WebhookSecret: "work-webhook",
|
|
}
|
|
personalCredentials := DeploymentAppCredentials{
|
|
PrivateKey: "personal-key", ClientSecret: "personal-client", WebhookSecret: "personal-webhook",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, work, workCredentials); err != nil {
|
|
t.Fatalf("save work registration: %v", err)
|
|
}
|
|
if err := repository.SaveRegistration(ctx, personal, personalCredentials); err != nil {
|
|
t.Fatalf("save personal registration: %v", err)
|
|
}
|
|
if work.CredentialSecretID == personal.CredentialSecretID || len(secrets.values) != 2 {
|
|
t.Fatalf("credential bundles are not independent: work=%q personal=%q values=%v",
|
|
work.CredentialSecretID, personal.CredentialSecretID, secrets.values)
|
|
}
|
|
gotWork, gotWorkCredentials, err := repository.LoadRegistration(ctx, work.ID)
|
|
if err != nil || gotWork == nil || gotWorkCredentials != workCredentials {
|
|
t.Fatalf("loaded work registration = %+v, credentials = %+v, err %v",
|
|
gotWork, gotWorkCredentials, err)
|
|
}
|
|
gotPersonal, gotPersonalCredentials, err := repository.LoadRegistration(ctx, personal.ID)
|
|
if err != nil || gotPersonal == nil || gotPersonalCredentials != personalCredentials {
|
|
t.Fatalf("loaded personal registration = %+v, credentials = %+v, err %v",
|
|
gotPersonal, gotPersonalCredentials, err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationDeleteIsRestrictedByWorkspaceAndPersonalReferences(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1")
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
registration := newAppRegistration("registration-1", 101, "Work", now)
|
|
credentials := DeploymentAppCredentials{
|
|
PrivateKey: "key", ClientSecret: "client", WebhookSecret: "webhook",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, registration, credentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: "github.com", InstallationID: &installationID,
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
AppRegistrationID: registration.ID, Status: ConnectionStatusActive,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.UpsertUserConnection(ctx, &UserConnection{
|
|
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: registration.ID,
|
|
GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive,
|
|
AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repository.DeleteRegistration(ctx, registration.ID); !errors.Is(err, ErrDeploymentAppInUse) {
|
|
t.Fatalf("delete referenced registration error = %v, want %v", err, ErrDeploymentAppInUse)
|
|
}
|
|
if err := store.DeleteUserConnection(ctx, "ws-1", "user-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.DeleteWorkspaceConnection(ctx, "ws-1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repository.DeleteRegistration(ctx, registration.ID); err != nil {
|
|
t.Fatalf("delete unreferenced registration: %v", err)
|
|
}
|
|
if len(secrets.values) != 0 {
|
|
t.Fatalf("registration credentials remain after delete: %v", secrets.values)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationDeletePreservesRegistrationWhenSecretDeleteFails(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
registration := newAppRegistration("registration-delete-failure", 101, "Work", time.Now().UTC())
|
|
credentials := DeploymentAppCredentials{
|
|
PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, registration, credentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secrets.deleteErr = errors.New("backend detail containing private-key")
|
|
secrets.deleteErrKey = registration.CredentialSecretID
|
|
|
|
err := repository.DeleteRegistration(ctx, registration.ID)
|
|
if !errors.Is(err, ErrAppRegistrationCredentialCleanup) {
|
|
t.Fatalf("DeleteRegistration() error = %v, want credential cleanup error", err)
|
|
}
|
|
if strings.Contains(err.Error(), "private-key") {
|
|
t.Fatalf("DeleteRegistration() leaked secret-store detail: %v", err)
|
|
}
|
|
got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID)
|
|
if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials {
|
|
t.Fatalf("registration after failed delete = %+v, credentials %+v, err %v", got, gotCredentials, loadErr)
|
|
}
|
|
|
|
secrets.deleteErr = nil
|
|
if err := repository.DeleteRegistration(ctx, registration.ID); err != nil {
|
|
t.Fatalf("retry DeleteRegistration(): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationDeleteRestoresAmbiguouslyDeletedSecret(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
registration := newAppRegistration("registration-ambiguous-delete", 101, "Work", time.Now().UTC())
|
|
credentials := DeploymentAppCredentials{
|
|
PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, registration, credentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secrets.deleteErr = errors.New("ambiguous delete")
|
|
secrets.deleteErrKey = registration.CredentialSecretID
|
|
secrets.deleteThenError = true
|
|
|
|
err := repository.DeleteRegistration(ctx, registration.ID)
|
|
if !errors.Is(err, ErrAppRegistrationCredentialCleanup) {
|
|
t.Fatalf("DeleteRegistration() error = %v, want credential cleanup error", err)
|
|
}
|
|
got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID)
|
|
if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials {
|
|
t.Fatalf("registration after ambiguous delete = %+v, credentials %+v, err %v", got, gotCredentials, loadErr)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationDeleteRestoresSecretWhenMetadataDeleteFails(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
registration := newAppRegistration("registration-metadata-delete-failure", 101, "Work", time.Now().UTC())
|
|
credentials := DeploymentAppCredentials{
|
|
PrivateKey: "private-key", ClientSecret: "client-secret", WebhookSecret: "webhook-secret",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, registration, credentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.Exec(`
|
|
CREATE TRIGGER fail_app_registration_delete
|
|
BEFORE DELETE ON github_app_registrations
|
|
BEGIN SELECT RAISE(FAIL, 'database detail'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
err := repository.DeleteRegistration(ctx, registration.ID)
|
|
if !errors.Is(err, ErrAppRegistrationDeletionFailed) {
|
|
t.Fatalf("DeleteRegistration() error = %v, want registration deletion error", err)
|
|
}
|
|
if strings.Contains(err.Error(), "database detail") {
|
|
t.Fatalf("DeleteRegistration() leaked database detail: %v", err)
|
|
}
|
|
got, gotCredentials, loadErr := repository.LoadRegistration(ctx, registration.ID)
|
|
if loadErr != nil || got == nil || got.Status != AppRegistrationStatusActive || gotCredentials != credentials {
|
|
t.Fatalf("registration after metadata failure = %+v, credentials %+v, err %v", got, gotCredentials, loadErr)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationFlowsSupersedeOnlyWithinWorkspace(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1", "ws-2")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
newFlow := func(state, registrationID, workspaceID string, createdAt time.Time) *DeploymentAppRegistrationFlow {
|
|
return &DeploymentAppRegistrationFlow{
|
|
StateHash: state, RegistrationID: registrationID, WorkspaceID: workspaceID,
|
|
UserID: "user-1", OwnerType: AppRegistrationOwnerOrganization,
|
|
OwnerLogin: "acme", DisplayName: "Kandev", Visibility: AppRegistrationVisibilityPrivate,
|
|
PublicBaseURL: "https://kandev.example.com", ManifestRevision: 1,
|
|
ExpiresAt: createdAt.Add(time.Hour), CreatedAt: createdAt,
|
|
}
|
|
}
|
|
first := newFlow("state-first", "registration-first", "ws-1", now)
|
|
other := newFlow("state-other", "registration-other", "ws-2", now.Add(time.Second))
|
|
replacement := newFlow("state-replacement", "registration-replacement", "ws-1", now.Add(2*time.Second))
|
|
for _, flow := range []*DeploymentAppRegistrationFlow{first, other, replacement} {
|
|
if err := store.CreateDeploymentAppRegistrationFlow(ctx, flow); err != nil {
|
|
t.Fatalf("create flow %s: %v", flow.StateHash, err)
|
|
}
|
|
}
|
|
storedFirst, err := store.GetDeploymentAppRegistrationFlow(ctx, first.StateHash)
|
|
if err != nil || storedFirst == nil || storedFirst.ConsumedAt == nil {
|
|
t.Fatalf("superseded first flow = %+v, err %v", storedFirst, err)
|
|
}
|
|
if _, err := store.ConsumeDeploymentAppRegistrationFlow(
|
|
ctx, other.StateHash, other.RegistrationID, now.Add(3*time.Second),
|
|
); err != nil {
|
|
t.Fatalf("other workspace flow was superseded: %v", err)
|
|
}
|
|
consumed, err := store.ConsumeDeploymentAppRegistrationFlow(
|
|
ctx, replacement.StateHash, replacement.RegistrationID, now.Add(3*time.Second),
|
|
)
|
|
if err != nil || consumed == nil || consumed.RegistrationID != replacement.RegistrationID {
|
|
t.Fatalf("replacement flow = %+v, err %v", consumed, err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationWebhookDeliveryIdentityIsComposite(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
for _, registration := range []*AppRegistration{
|
|
newAppRegistration("registration-1", 101, "Work", now),
|
|
newAppRegistration("registration-2", 202, "Personal", now),
|
|
} {
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, registrationID := range []string{"registration-1", "registration-2"} {
|
|
inserted, err := store.RecordWebhookDelivery(ctx, &WebhookDelivery{
|
|
AppRegistrationID: registrationID, DeliveryID: "delivery-1", Event: "installation",
|
|
})
|
|
if err != nil || !inserted {
|
|
t.Fatalf("record %s/delivery-1: inserted %v, err %v", registrationID, inserted, err)
|
|
}
|
|
}
|
|
inserted, err := store.RecordWebhookDelivery(ctx, &WebhookDelivery{
|
|
AppRegistrationID: "registration-1", DeliveryID: "delivery-1", Event: "installation",
|
|
})
|
|
if err != nil || inserted {
|
|
t.Fatalf("duplicate registration delivery: inserted %v, err %v", inserted, err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationSchemaDropsUnpublishedSingletonState(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
if _, err := store.db.Exec(`
|
|
CREATE TABLE github_app_registration (
|
|
singleton_id INTEGER PRIMARY KEY,
|
|
github_host TEXT NOT NULL,
|
|
app_id BIGINT NOT NULL
|
|
);
|
|
INSERT INTO github_app_registration (singleton_id, github_host, app_id)
|
|
VALUES (1, 'github.com', 999)
|
|
`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := NewStore(store.db, store.ro); err != nil {
|
|
t.Fatalf("replay store with unpublished singleton: %v", err)
|
|
}
|
|
var catalogCount int
|
|
if err := store.ro.GetContext(ctx, &catalogCount, `SELECT COUNT(*) FROM github_app_registrations`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if catalogCount != 0 {
|
|
t.Fatalf("unpublished singleton migrated into catalog: count = %d", catalogCount)
|
|
}
|
|
var singletonCount int
|
|
if err := store.ro.GetContext(ctx, &singletonCount,
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'github_app_registration'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if singletonCount != 0 {
|
|
t.Fatalf("unpublished singleton still exists: count = %d", singletonCount)
|
|
}
|
|
}
|
|
|
|
func TestPersonalConnectionDatabaseRejectsDifferentWorkspaceRegistration(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
for _, registration := range []*AppRegistration{
|
|
newAppRegistration("registration-work", 101, "Work", now),
|
|
newAppRegistration("registration-personal", 202, "Personal", now),
|
|
} {
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: defaultGitHubHost, InstallationID: &installationID,
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
AppRegistrationID: "registration-work", Status: ConnectionStatusActive,
|
|
CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO github_user_connections (
|
|
workspace_id, user_id, app_registration_id, github_user_id, login, status,
|
|
access_expires_at, credential_generation, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"ws-1", "user-1", "registration-personal", 7, "octocat", "active",
|
|
now.Add(time.Hour), 1, now, now)
|
|
if err == nil || !strings.Contains(err.Error(), "must match workspace") {
|
|
t.Fatalf("mismatched direct insert error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceConnectionDatabaseRejectsSwitchWithPersonalConnection(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "ws-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
for _, registration := range []*AppRegistration{
|
|
newAppRegistration("registration-work", 101, "Work", now),
|
|
newAppRegistration("registration-personal", 202, "Personal", now),
|
|
} {
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, registration); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
GitHubHost: defaultGitHubHost, InstallationID: &installationID,
|
|
InstallationAccountLogin: "acme", InstallationAccountType: "Organization",
|
|
AppRegistrationID: "registration-work", Status: ConnectionStatusActive,
|
|
CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.UpsertUserConnection(ctx, &UserConnection{
|
|
WorkspaceID: "ws-1", UserID: "user-1", AppRegistrationID: "registration-work",
|
|
GitHubUserID: 7, Login: "octocat", Status: ConnectionStatusActive,
|
|
AccessExpiresAt: now.Add(time.Hour), CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := store.db.ExecContext(ctx, `
|
|
UPDATE github_workspace_connections
|
|
SET app_registration_id = ?
|
|
WHERE workspace_id = ?`, "registration-personal", "ws-1")
|
|
if err == nil || !strings.Contains(err.Error(), "must match personal") {
|
|
t.Fatalf("direct workspace switch error = %v", err)
|
|
}
|
|
|
|
_, err = store.db.ExecContext(ctx,
|
|
`DELETE FROM github_workspace_connections WHERE workspace_id = ?`, "ws-1")
|
|
if err == nil || !strings.Contains(err.Error(), "still has personal") {
|
|
t.Fatalf("direct workspace disconnect error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportPreparationIsExactAndSingleUse(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
preparation := &AppRegistrationImportPreparation{
|
|
RegistrationID: "d8fcfe25-6dbc-45d2-8d11-8a0c1f022c85",
|
|
WorkspaceID: "workspace-1", UserID: DefaultUserID,
|
|
PublicBaseURL: "https://kandev.example", ExpiresAt: now.Add(time.Minute), CreatedAt: now,
|
|
}
|
|
if err := store.CreateAppRegistrationImportPreparation(ctx, preparation); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, attempt := range []struct {
|
|
workspaceID, userID, publicBaseURL string
|
|
}{
|
|
{"wrong-workspace", DefaultUserID, preparation.PublicBaseURL},
|
|
{preparation.WorkspaceID, "wrong-user", preparation.PublicBaseURL},
|
|
{preparation.WorkspaceID, DefaultUserID, "https://other.example"},
|
|
} {
|
|
if _, err := store.ConsumeAppRegistrationImportPreparation(
|
|
ctx, preparation.RegistrationID, attempt.workspaceID, attempt.userID, attempt.publicBaseURL, now,
|
|
); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) {
|
|
t.Fatalf("wrong binding error = %v", err)
|
|
}
|
|
}
|
|
consumed, err := store.ConsumeAppRegistrationImportPreparation(
|
|
ctx, preparation.RegistrationID, preparation.WorkspaceID, preparation.UserID,
|
|
preparation.PublicBaseURL, now,
|
|
)
|
|
if err != nil || consumed == nil || consumed.ConsumedAt == nil {
|
|
t.Fatalf("consume = %+v, err %v", consumed, err)
|
|
}
|
|
if _, err := store.ConsumeAppRegistrationImportPreparation(
|
|
ctx, preparation.RegistrationID, preparation.WorkspaceID, preparation.UserID,
|
|
preparation.PublicBaseURL, now,
|
|
); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) {
|
|
t.Fatalf("replay error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationImportPreparationCannotOverwriteReservedID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
registrationID := "e72d588e-e0cb-44d4-ae7d-61a6171d4d1a"
|
|
preparation := &AppRegistrationImportPreparation{
|
|
RegistrationID: registrationID, WorkspaceID: "workspace-1", UserID: DefaultUserID,
|
|
PublicBaseURL: "https://kandev.example", ExpiresAt: now.Add(time.Minute), CreatedAt: now,
|
|
}
|
|
if err := store.CreateAppRegistrationImportPreparation(ctx, preparation); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
existing := newAppRegistration(registrationID, 999, "Existing", now)
|
|
if err := store.UpsertDeploymentAppRegistration(ctx, existing); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.ConsumeAppRegistrationImportPreparation(
|
|
ctx, registrationID, preparation.WorkspaceID, preparation.UserID,
|
|
preparation.PublicBaseURL, now,
|
|
); !errors.Is(err, ErrAppRegistrationImportPreparationUnavailable) {
|
|
t.Fatalf("existing ID consume error = %v", err)
|
|
}
|
|
stored, err := store.GetAppRegistration(ctx, registrationID)
|
|
if err != nil || stored == nil || stored.AppID != 999 || stored.DisplayName != "Existing" {
|
|
t.Fatalf("existing registration changed = %+v, err %v", stored, err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAppRegistrationNeverOverwritesExistingID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
secrets := newFakeConnectionSecrets()
|
|
repository := NewAppRegistrationRepository(store, secrets)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
existing := newAppRegistration("registration-existing", 101, "Existing", now)
|
|
existingCredentials := DeploymentAppCredentials{
|
|
PrivateKey: "existing-key", ClientSecret: "existing-client", WebhookSecret: "existing-webhook",
|
|
}
|
|
if err := repository.SaveRegistration(ctx, existing, existingCredentials); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
candidate := newAppRegistration(existing.ID, 202, "Replacement", now)
|
|
err := repository.CreateRegistration(ctx, candidate, DeploymentAppCredentials{
|
|
PrivateKey: "replacement-key", ClientSecret: "replacement-client", WebhookSecret: "replacement-webhook",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("create-only registration overwrote existing ID")
|
|
}
|
|
stored, credentials, err := repository.LoadRegistration(ctx, existing.ID)
|
|
if err != nil || stored == nil || stored.AppID != existing.AppID || stored.DisplayName != existing.DisplayName ||
|
|
credentials != existingCredentials {
|
|
t.Fatalf("existing registration changed = %+v, credentials=%+v, err %v", stored, credentials, err)
|
|
}
|
|
}
|