37b56ba11c
* feat(automations): reliable cron scheduling and webhook-driven push/CI triggers Automations were "wounded": cron schedules mishandled timezones and some never fired, and the GitHub push/CI trigger types were coming-soon stubs. Cron (fix): - The scheduler reduced every cron expression to a fixed interval derived only from a */N step in the minute/hour field, so any pinned schedule (0 9 * * *, 30 14 * * *, weekday ranges, day-of-month) never fired, and the configured timezone was parsed but never used. - Replace the interval approximation with robfig/cron/v3: shouldFire now computes the true next fire time in the configured timezone (DST-aware, defaulting to UTC for host-independence), anchored to last-evaluated or creation time. Dead interval-parsing helpers and shorthand constants removed. GitHub push + CI triggers (feat): - Implement push and CI-failure triggers end to end via the existing per-workspace GitHub App webhook plumbing (#1810): the HMAC-verified webhook service now parses push and completed check_run events, resolves the installation to its workspaces, and publishes normalized bus events. - A new automation subscriber matches enabled github_push / github_ci triggers by workspace + repo + branch/conclusion/check-name filters and fires them (deduped by commit SHA / check-run id). The old poll-based stub evaluators are removed; the PR poll path is unchanged. - Subscribe the App manifest to push/check_run and enable both trigger types in the registry (CI defaults to the failure conclusion). Docs: - Update docs/public/feature-status.md and automation-and-mcp.md so the cron and push/CI rows reflect shipped reality. Tests: regression tests pin the previously-never-firing cron shapes, timezone/DST behavior, and host-independence; backend tests cover the webhook parse->publish and subscriber match/fire/dedup flows for both push and CI-failure. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(automations): populate push.message and ci.url placeholders in fired trigger data Address PR review blockers: the default push and CI automation prompts reference {{push.message}} and {{ci.url}}, which the interpolator maps to data["message"] / data["html_url"] — but the webhook subscriber never populated those fields, so both always rendered empty. - Parse head_commit.message (push) and check_run.html_url (CI) in the GitHub webhook service and carry them on the published bus payloads. - Include "message" / "html_url" in the fired trigger data so the placeholders resolve; extend the webhook and subscriber tests to assert both flow through end to end. Non-blocking review cleanups: - Replace the misleading reuse of matchesAuthors for CI conclusions/check names with a clearly-named matchesFilterValue helper. - Drop dead context.Background() line and its unused import from the cron regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(automations): scope webhook triggers to active installs and per-branch dedup Address CodeRabbit review on the push/CI webhook triggers: - Security: resolveWorkspaceIDs now only returns connections whose status is active, so a suspended or revoked GitHub App installation can no longer fire push/CI automations. - Correctness: include the branch in the push dedup key (push:owner/name@branch@sha) so the same commit pushed to two matching branches fires once per branch instead of being suppressed. - Correctness: add an optional head-branch filter to github_ci triggers and gate CI matching on it, matching the push trigger's branch-scoping. - Stability: make the webhook subscriber's Start retryable — a subscription failure rolls back partial subscriptions and leaves the subscriber un-started so a later Start re-attempts, instead of permanently disabling push/CI processing. - Tests: cover multi-connection workspace resolution (dedup + inactive filtering), per-branch push dedup, and CI branch filtering. - Docs: note the CI branch filter and per-branch push dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(automations): cover webhook subscriber Start retry-after-failure path Add TestGitHubWebhookSubscriberStartRetryableAfterFailure with a minimal flaky bus whose second Subscribe fails, pinning the retry contract: a subscription failure rolls back the partial subscription and leaves the subscriber un-started, and a later Start re-attempts and succeeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
712 lines
26 KiB
Go
712 lines
26 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type githubWebhookMemoryStore struct {
|
|
workspaces map[int64][]*WorkspaceConnection
|
|
users map[int64][]*UserConnection
|
|
deliveries map[string]*WebhookDelivery
|
|
updates int
|
|
updateErr error
|
|
beforeTransition func()
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) ClaimWebhookDelivery(
|
|
_ context.Context, delivery *WebhookDelivery, staleBefore time.Time,
|
|
) (WebhookDeliveryClaim, error) {
|
|
if s.deliveries == nil {
|
|
s.deliveries = make(map[string]*WebhookDelivery)
|
|
}
|
|
key := delivery.AppRegistrationID + ":" + delivery.DeliveryID
|
|
existing, exists := s.deliveries[key]
|
|
if exists && existing.Status != WebhookDeliveryStatusFailed &&
|
|
(existing.Status != WebhookDeliveryStatusReceived || existing.ReceivedAt.After(staleBefore)) {
|
|
return WebhookDeliveryClaim{Status: existing.Status}, nil
|
|
}
|
|
copy := *delivery
|
|
s.deliveries[key] = ©
|
|
return WebhookDeliveryClaim{Acquired: true, Status: WebhookDeliveryStatusReceived}, nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) CompleteWebhookDelivery(
|
|
_ context.Context, deliveryID string, status WebhookDeliveryStatus, result string, processedAt time.Time,
|
|
) error {
|
|
var delivery *WebhookDelivery
|
|
for _, candidate := range s.deliveries {
|
|
if candidate.DeliveryID == deliveryID {
|
|
delivery = candidate
|
|
break
|
|
}
|
|
}
|
|
if delivery == nil {
|
|
return errors.New("delivery not found")
|
|
}
|
|
delivery.Status = status
|
|
delivery.Result = result
|
|
delivery.ProcessedAt = &processedAt
|
|
return nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) CompleteAppRegistrationWebhookDelivery(
|
|
_ context.Context,
|
|
registrationID, deliveryID string,
|
|
status WebhookDeliveryStatus,
|
|
result string,
|
|
processedAt time.Time,
|
|
) error {
|
|
delivery := s.deliveries[registrationID+":"+deliveryID]
|
|
if delivery == nil {
|
|
return errors.New("delivery not found")
|
|
}
|
|
delivery.Status = status
|
|
delivery.Result = result
|
|
delivery.ProcessedAt = &processedAt
|
|
return nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) ListWorkspaceConnectionsByInstallation(
|
|
_ context.Context, installationID int64,
|
|
) ([]*WorkspaceConnection, error) {
|
|
return s.workspaces[installationID], nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) ListWorkspaceConnectionsByAppInstallation(
|
|
_ context.Context, registrationID string, installationID int64,
|
|
) ([]*WorkspaceConnection, error) {
|
|
connections := s.workspaces[installationID]
|
|
matched := make([]*WorkspaceConnection, 0, len(connections))
|
|
for _, connection := range connections {
|
|
if connection != nil && connection.AppRegistrationID == registrationID {
|
|
matched = append(matched, connection)
|
|
}
|
|
}
|
|
return matched, nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) TransitionWorkspaceInstallationConnection(
|
|
_ context.Context,
|
|
expected, next *WorkspaceConnection,
|
|
) (bool, error) {
|
|
if s.beforeTransition != nil {
|
|
s.beforeTransition()
|
|
s.beforeTransition = nil
|
|
}
|
|
if s.updateErr != nil {
|
|
err := s.updateErr
|
|
s.updateErr = nil
|
|
return false, err
|
|
}
|
|
for _, current := range s.workspaces[*expected.InstallationID] {
|
|
if current.WorkspaceID != expected.WorkspaceID {
|
|
continue
|
|
}
|
|
if current.Source != expected.Source || current.InstallationID == nil ||
|
|
*current.InstallationID != *expected.InstallationID ||
|
|
current.AppRegistrationID != expected.AppRegistrationID ||
|
|
current.CredentialGeneration != expected.CredentialGeneration || current.Status != expected.Status {
|
|
return false, nil
|
|
}
|
|
*current = *next
|
|
s.updates++
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) ListUserConnectionsByGitHubUser(
|
|
_ context.Context, githubUserID int64,
|
|
) ([]*UserConnection, error) {
|
|
return s.users[githubUserID], nil
|
|
}
|
|
|
|
func (s *githubWebhookMemoryStore) ListUserConnectionsByAppGitHubUser(
|
|
_ context.Context, registrationID string, githubUserID int64,
|
|
) ([]*UserConnection, error) {
|
|
connections := s.users[githubUserID]
|
|
matched := make([]*UserConnection, 0, len(connections))
|
|
for _, connection := range connections {
|
|
if connection != nil && connection.AppRegistrationID == registrationID {
|
|
matched = append(matched, connection)
|
|
}
|
|
}
|
|
return matched, nil
|
|
}
|
|
|
|
type webhookPersonalRevoker struct {
|
|
revoked []string
|
|
}
|
|
|
|
type webhookInstallationVerifier struct {
|
|
installation AppInstallation
|
|
err error
|
|
}
|
|
|
|
func (v *webhookInstallationVerifier) GetInstallation(
|
|
context.Context,
|
|
int64,
|
|
) (AppInstallation, error) {
|
|
return v.installation, v.err
|
|
}
|
|
|
|
type webhookPersonalReconciler struct {
|
|
revoked bool
|
|
calls int
|
|
}
|
|
|
|
func (r *webhookPersonalReconciler) ReconcileAuthorizationRevocation(
|
|
context.Context,
|
|
*UserConnection,
|
|
) (bool, error) {
|
|
r.calls++
|
|
return r.revoked, nil
|
|
}
|
|
|
|
func (r *webhookPersonalRevoker) RevokePersonalConnection(_ context.Context, workspaceID, userID string) error {
|
|
r.revoked = append(r.revoked, workspaceID+":"+userID)
|
|
return nil
|
|
}
|
|
|
|
type webhookRepoUpdater struct {
|
|
updates []InstallationRepositoriesChange
|
|
err error
|
|
}
|
|
|
|
func newRegistrationWebhookService(
|
|
registrationID, secret string,
|
|
store githubWebhookStore,
|
|
repositories installationRepositoryUpdater,
|
|
personal personalConnectionRevoker,
|
|
reconciliation ...GitHubWebhookReconciliation,
|
|
) *GitHubWebhookService {
|
|
return NewAppRegistrationWebhookService(
|
|
registrationID, secret, store, repositories, personal, nil, reconciliation...,
|
|
)
|
|
}
|
|
|
|
func TestGitHubWebhookDeliveryIdentityIncludesRegistration(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{}
|
|
payload := []byte(`{}`)
|
|
request := signedWebhookRequest("shared-secret", "delivery-1", "ping", payload)
|
|
|
|
for _, registrationID := range []string{"registration-a", "registration-b"} {
|
|
service := newRegistrationWebhookService(registrationID, "shared-secret", store, nil, nil)
|
|
result, err := service.Handle(context.Background(), request)
|
|
if err != nil || result.Duplicate {
|
|
t.Fatalf("registration %s Handle() = %+v, err %v", registrationID, result, err)
|
|
}
|
|
}
|
|
if len(store.deliveries) != 2 {
|
|
t.Fatalf("deliveries = %#v, want one per registration", store.deliveries)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookOnlyMutatesMatchingRegistrationInstallation(t *testing.T) {
|
|
installationID := int64(42)
|
|
work := &WorkspaceConnection{
|
|
WorkspaceID: "work", AppRegistrationID: "registration-work",
|
|
Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID,
|
|
Status: ConnectionStatusActive, CredentialGeneration: 1,
|
|
}
|
|
personal := &WorkspaceConnection{
|
|
WorkspaceID: "personal", AppRegistrationID: "registration-personal",
|
|
Source: ConnectionSourceGitHubAppInstallation, InstallationID: &installationID,
|
|
Status: ConnectionStatusActive, CredentialGeneration: 1,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{
|
|
installationID: {work, personal},
|
|
}}
|
|
service := newRegistrationWebhookService("registration-work", "work-secret", store, nil, nil)
|
|
request := signedWebhookRequest(
|
|
"work-secret", "delivery-1", "installation",
|
|
[]byte(`{"action":"suspend","installation":{"id":42}}`),
|
|
)
|
|
|
|
result, err := service.Handle(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 1 || work.Status != ConnectionStatusSuspended ||
|
|
personal.Status != ConnectionStatusActive {
|
|
t.Fatalf("result=%+v work=%+v personal=%+v", result, work, personal)
|
|
}
|
|
}
|
|
|
|
func (u *webhookRepoUpdater) ApplyInstallationRepositories(
|
|
_ context.Context, change InstallationRepositoriesChange,
|
|
) (bool, error) {
|
|
if u.err != nil {
|
|
err := u.err
|
|
u.err = nil
|
|
return false, err
|
|
}
|
|
u.updates = append(u.updates, change)
|
|
return true, nil
|
|
}
|
|
|
|
func TestGitHubWebhookRejectsInvalidSignatureBeforeDedupe(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{}
|
|
service := newRegistrationWebhookService("registration-b", "registration-b-secret", store, nil, nil)
|
|
wrongRouteRequest := signedWebhookRequest(
|
|
"registration-a-secret", "delivery-1", "installation", []byte(`{"action":`),
|
|
)
|
|
_, err := service.Handle(context.Background(), wrongRouteRequest)
|
|
if !errors.Is(err, ErrInvalidWebhookSignature) || len(store.deliveries) != 0 {
|
|
t.Fatalf("Handle() error = %v, deliveries = %v", err, store.deliveries)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookAuthorizationRevocationOnlyMatchesRegistration(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: {
|
|
{WorkspaceID: "work", UserID: "user-1", AppRegistrationID: "registration-work", GitHubUserID: 11},
|
|
{WorkspaceID: "personal", UserID: "user-1", AppRegistrationID: "registration-personal", GitHubUserID: 11},
|
|
}}}
|
|
revoker := &webhookPersonalRevoker{}
|
|
service := newRegistrationWebhookService("registration-work", "work-secret", store, nil, revoker)
|
|
request := signedWebhookRequest(
|
|
"work-secret", "delivery-authorization", "github_app_authorization",
|
|
[]byte(`{"action":"revoked","sender":{"id":11}}`),
|
|
)
|
|
|
|
result, err := service.Handle(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 1 || len(revoker.revoked) != 1 || revoker.revoked[0] != "work:user-1" {
|
|
t.Fatalf("result=%+v revoked=%v", result, revoker.revoked)
|
|
}
|
|
}
|
|
|
|
func TestAppRegistrationWebhookHealthUpdateIsRegistrationLocal(t *testing.T) {
|
|
store := newTestStore(t)
|
|
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)
|
|
}
|
|
}
|
|
|
|
updated, err := store.updateAppRegistrationWebhookHealth(
|
|
ctx, "registration-work", 1, DeploymentAppWebhookVerified, now, "",
|
|
)
|
|
if err != nil || !updated {
|
|
t.Fatalf("update work health = %v, err %v", updated, err)
|
|
}
|
|
work, err := store.GetAppRegistration(ctx, "registration-work")
|
|
if err != nil || work.WebhookStatus != DeploymentAppWebhookVerified || work.LastWebhookAt == nil {
|
|
t.Fatalf("work registration = %+v, err %v", work, err)
|
|
}
|
|
personal, err := store.GetAppRegistration(ctx, "registration-personal")
|
|
if err != nil || personal.WebhookStatus != DeploymentAppWebhookUnverified || personal.LastWebhookAt != nil {
|
|
t.Fatalf("personal registration = %+v, err %v", personal, err)
|
|
}
|
|
|
|
updated, err = store.updateAppRegistrationWebhookHealth(
|
|
ctx, "registration-work", 0, DeploymentAppWebhookFailing, now, "stale",
|
|
)
|
|
if err != nil || updated {
|
|
t.Fatalf("stale generation update = %v, err %v", updated, err)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookConcurrentDeliveryIsProcessedOncePerRegistration(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ctx := context.Background()
|
|
now := time.Now().UTC()
|
|
seedConnectionWorkspaces(t, store, "workspace-1")
|
|
if err := store.UpsertDeploymentAppRegistration(
|
|
ctx, newAppRegistration("registration-work", 101, "Work", now),
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
installationID := int64(42)
|
|
if err := store.UpsertWorkspaceConnection(ctx, &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", AppRegistrationID: "registration-work",
|
|
Source: ConnectionSourceGitHubAppInstallation, GitHubHost: "github.com",
|
|
InstallationID: &installationID, InstallationAccountLogin: "acme",
|
|
InstallationAccountType: "Organization", Status: ConnectionStatusActive,
|
|
CredentialGeneration: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := NewAppRegistrationWebhookService(
|
|
"registration-work", "work-secret", store, nil, nil, nil,
|
|
)
|
|
request := signedWebhookRequest(
|
|
"work-secret", "delivery-concurrent", "installation",
|
|
[]byte(`{"action":"suspend","installation":{"id":42}}`),
|
|
)
|
|
|
|
type outcome struct {
|
|
result GitHubWebhookResult
|
|
err error
|
|
}
|
|
ready := sync.WaitGroup{}
|
|
ready.Add(2)
|
|
start := make(chan struct{})
|
|
outcomes := make(chan outcome, 2)
|
|
for range 2 {
|
|
go func() {
|
|
ready.Done()
|
|
<-start
|
|
result, err := service.Handle(ctx, request)
|
|
outcomes <- outcome{result: result, err: err}
|
|
}()
|
|
}
|
|
ready.Wait()
|
|
close(start)
|
|
|
|
affected, duplicates := 0, 0
|
|
for range 2 {
|
|
outcome := <-outcomes
|
|
if outcome.err != nil {
|
|
t.Fatalf("Handle() error = %v", outcome.err)
|
|
}
|
|
affected += outcome.result.Affected
|
|
if outcome.result.Duplicate {
|
|
duplicates++
|
|
}
|
|
}
|
|
if affected != 1 || duplicates != 1 {
|
|
t.Fatalf("affected=%d duplicates=%d", affected, duplicates)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookDeduplicatesInstallationTransition(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
payload := []byte(`{"action":"suspend","installation":{"id":42}}`)
|
|
request := signedWebhookRequest("webhook-secret", "delivery-1", "installation", payload)
|
|
|
|
first, err := service.Handle(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("first Handle() error = %v", err)
|
|
}
|
|
second, err := service.Handle(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("duplicate Handle() error = %v", err)
|
|
}
|
|
if first.Affected != 1 || !second.Duplicate || store.updates != 1 ||
|
|
connection.Status != ConnectionStatusSuspended || connection.CredentialGeneration != 3 {
|
|
t.Fatalf("first = %+v, second = %+v, connection = %+v, updates = %d", first, second, connection, store.updates)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookUnsuspendsAndDeletesKnownInstallation(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, Status: ConnectionStatusSuspended, CredentialGeneration: 3,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
|
|
unsuspend := []byte(`{"action":"unsuspend","installation":{"id":42}}`)
|
|
if _, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-unsuspend", "installation", unsuspend),
|
|
); err != nil {
|
|
t.Fatalf("unsuspend Handle() error = %v", err)
|
|
}
|
|
if connection.Status != ConnectionStatusActive || connection.CredentialGeneration != 4 {
|
|
t.Fatalf("unsuspended connection = %+v", connection)
|
|
}
|
|
|
|
deleted := []byte(`{"action":"deleted","installation":{"id":42}}`)
|
|
if _, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-delete", "installation", deleted),
|
|
); err != nil {
|
|
t.Fatalf("delete Handle() error = %v", err)
|
|
}
|
|
if connection.Status != ConnectionStatusRevoked || connection.CredentialGeneration != 5 {
|
|
t.Fatalf("deleted connection = %+v", connection)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookLateUnsuspendCannotReactivateDeletedInstallation(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, Status: ConnectionStatusRevoked, CredentialGeneration: 5,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
payload := []byte(`{"action":"unsuspend","installation":{"id":42}}`)
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-late", "installation", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 0 || connection.Status != ConnectionStatusRevoked || connection.CredentialGeneration != 5 {
|
|
t.Fatalf("Handle() = %+v, connection = %+v", result, connection)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookReconcilesOutOfOrderInstallationEventsWithProvider(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, InstallationAccountLogin: "acme",
|
|
InstallationAccountType: "Organization", Status: ConnectionStatusSuspended,
|
|
CredentialGeneration: 5,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}}
|
|
verifier := &webhookInstallationVerifier{installation: AppInstallation{
|
|
ID: 42, AccountLogin: "acme", AccountType: "Organization",
|
|
}}
|
|
service := NewGitHubWebhookService(
|
|
"webhook-secret", store, nil, nil, nil,
|
|
GitHubWebhookReconciliation{Installations: verifier},
|
|
)
|
|
|
|
result, err := service.Handle(context.Background(), signedWebhookRequest(
|
|
"webhook-secret", "delivery-delayed-suspend", "installation",
|
|
[]byte(`{"action":"suspend","installation":{"id":42}}`),
|
|
))
|
|
if err != nil || result.Affected != 1 || connection.Status != ConnectionStatusActive {
|
|
t.Fatalf("delayed suspend result=%+v err=%v connection=%+v", result, err, connection)
|
|
}
|
|
suspendedAt := time.Now().UTC()
|
|
verifier.installation.SuspendedAt = &suspendedAt
|
|
result, err = service.Handle(context.Background(), signedWebhookRequest(
|
|
"webhook-secret", "delivery-delayed-unsuspend", "installation",
|
|
[]byte(`{"action":"unsuspend","installation":{"id":42}}`),
|
|
))
|
|
if err != nil || result.Affected != 1 || connection.Status != ConnectionStatusSuspended {
|
|
t.Fatalf("delayed unsuspend result=%+v err=%v connection=%+v", result, err, connection)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookUnknownInstallationDoesNotCreateBinding(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
payload := []byte(`{"action":"created","installation":{"id":999}}`)
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-2", "installation", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 0 || result.Status != WebhookDeliveryStatusIgnored || store.updates != 0 {
|
|
t.Fatalf("Handle() = %+v, updates = %d", result, store.updates)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookStaleInstallationRowCannotReplacePAT(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2,
|
|
}
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {connection}}}
|
|
store.beforeTransition = func() {
|
|
connection.Source = ConnectionSourcePAT
|
|
connection.InstallationID = nil
|
|
connection.Login = "octocat"
|
|
connection.CredentialGeneration = 3
|
|
}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
payload := []byte(`{"action":"suspend","installation":{"id":42}}`)
|
|
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-stale-binding", "installation", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 0 || store.updates != 0 || connection.Source != ConnectionSourcePAT ||
|
|
connection.InstallationID != nil || connection.Status != ConnectionStatusActive ||
|
|
connection.CredentialGeneration != 3 {
|
|
t.Fatalf("Handle() = %+v, connection = %+v, updates = %d", result, connection, store.updates)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookAppliesRepositoryChangesOnlyToKnownBindings(t *testing.T) {
|
|
installationID := int64(42)
|
|
store := &githubWebhookMemoryStore{workspaces: map[int64][]*WorkspaceConnection{42: {{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
AppRegistrationID: "registration-work", InstallationID: &installationID,
|
|
Status: ConnectionStatusActive, CredentialGeneration: 3,
|
|
}}}}
|
|
repos := &webhookRepoUpdater{}
|
|
service := newRegistrationWebhookService("registration-work", "webhook-secret", store, repos, nil)
|
|
payload := []byte(`{
|
|
"action":"added","installation":{"id":42},
|
|
"repositories_added":[{"id":7,"full_name":"acme/repo"}],"repositories_removed":[]
|
|
}`)
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-3", "installation_repositories", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 1 || len(repos.updates) != 1 || repos.updates[0].WorkspaceID != "workspace-1" ||
|
|
repos.updates[0].AppRegistrationID != "registration-work" ||
|
|
repos.updates[0].CredentialGeneration != 3 || len(repos.updates[0].Added) != 1 ||
|
|
repos.updates[0].Added[0].FullName != "acme/repo" {
|
|
t.Fatalf("Handle() = %+v, repository updates = %+v", result, repos.updates)
|
|
}
|
|
}
|
|
|
|
func TestInstallationRepositoryUpdateIgnoresReplacedAppBinding(t *testing.T) {
|
|
service, _ := newWorkspaceConnectionService(t, "octocat")
|
|
installationID := int64(42)
|
|
appConnection := activeAppWorkspace("ws-1", installationID)
|
|
appConnection.CredentialGeneration = 2
|
|
if err := service.store.UpsertWorkspaceConnection(context.Background(), appConnection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.store.UpsertWorkspaceSettings(context.Background(), &WorkspaceSettings{
|
|
WorkspaceID: "ws-1", RepoScopeMode: RepoScopeModeRepos,
|
|
RepoScopeRepos: []RepoFilter{{Owner: "acme", Name: "repo"}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.store.UpsertWorkspaceConnection(context.Background(), &WorkspaceConnection{
|
|
WorkspaceID: "ws-1", Source: ConnectionSourcePAT, Login: "octocat",
|
|
GitHubHost: "github.com", Status: ConnectionStatusActive, CredentialGeneration: 3,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
updated, err := (&installationRepositorySettingsUpdater{service: service}).ApplyInstallationRepositories(
|
|
context.Background(),
|
|
InstallationRepositoriesChange{
|
|
WorkspaceID: "ws-1", InstallationID: installationID,
|
|
ConnectionSource: ConnectionSourceGitHubAppInstallation, CredentialGeneration: 2,
|
|
Action: "removed", Removed: []InstallationRepository{{ID: 7, FullName: "acme/repo"}},
|
|
},
|
|
)
|
|
if err != nil || updated {
|
|
t.Fatalf("ApplyInstallationRepositories() updated=%v, err=%v", updated, err)
|
|
}
|
|
settings, err := service.store.GetWorkspaceSettings(context.Background(), "ws-1")
|
|
if err != nil || len(settings.RepoScopeRepos) != 1 || settings.RepoScopeRepos[0].Name != "repo" {
|
|
t.Fatalf("workspace settings after stale webhook = %+v, err=%v", settings, err)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookAuthorizationRevocationRemovesExactUsers(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: {
|
|
{WorkspaceID: "workspace-1", UserID: "user-1", GitHubUserID: 11},
|
|
{WorkspaceID: "workspace-2", UserID: "user-9", GitHubUserID: 11},
|
|
}}}
|
|
revoker := &webhookPersonalRevoker{}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, revoker, nil)
|
|
payload := []byte(`{"action":"revoked","sender":{"id":11,"login":"octocat"}}`)
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-4", "github_app_authorization", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Affected != 2 || len(revoker.revoked) != 2 || revoker.revoked[0] != "workspace-1:user-1" {
|
|
t.Fatalf("Handle() = %+v, revoked = %v", result, revoker.revoked)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookLateAuthorizationRevokePreservesVerifiedReconnect(t *testing.T) {
|
|
store := &githubWebhookMemoryStore{users: map[int64][]*UserConnection{11: {{
|
|
WorkspaceID: "workspace-1", UserID: "user-1", GitHubUserID: 11,
|
|
CredentialGeneration: 2,
|
|
}}}}
|
|
reconciler := &webhookPersonalReconciler{}
|
|
service := NewGitHubWebhookService(
|
|
"webhook-secret", store, nil, nil, nil,
|
|
GitHubWebhookReconciliation{Personal: reconciler},
|
|
)
|
|
payload := []byte(`{"action":"revoked","sender":{"id":11}}`)
|
|
result, err := service.Handle(context.Background(), signedWebhookRequest(
|
|
"webhook-secret", "delivery-late-revoke", "github_app_authorization", payload,
|
|
))
|
|
if err != nil || result.Affected != 0 || reconciler.calls != 1 {
|
|
t.Fatalf("Handle()=%+v err=%v reconciler calls=%d", result, err, reconciler.calls)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookRetriesFailedDelivery(t *testing.T) {
|
|
installationID := int64(42)
|
|
connection := &WorkspaceConnection{
|
|
WorkspaceID: "workspace-1", Source: ConnectionSourceGitHubAppInstallation,
|
|
InstallationID: &installationID, Status: ConnectionStatusActive, CredentialGeneration: 2,
|
|
}
|
|
store := &githubWebhookMemoryStore{
|
|
workspaces: map[int64][]*WorkspaceConnection{42: {connection}},
|
|
}
|
|
repos := &webhookRepoUpdater{err: errors.New("temporary database failure")}
|
|
service := NewGitHubWebhookService("webhook-secret", store, repos, nil, nil)
|
|
payload := []byte(`{
|
|
"action":"added","installation":{"id":42},
|
|
"repositories_added":[{"id":7,"full_name":"acme/repo"}],"repositories_removed":[]
|
|
}`)
|
|
request := signedWebhookRequest("webhook-secret", "delivery-retry", "installation_repositories", payload)
|
|
|
|
if _, err := service.Handle(context.Background(), request); err == nil {
|
|
t.Fatal("first Handle() unexpectedly succeeded")
|
|
}
|
|
if got := store.deliveries[":"+request.DeliveryID].Status; got != WebhookDeliveryStatusFailed {
|
|
t.Fatalf("failed delivery status = %q", got)
|
|
}
|
|
result, err := service.Handle(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("retry Handle() error = %v", err)
|
|
}
|
|
if result.Duplicate || result.Affected != 1 || len(repos.updates) != 1 ||
|
|
store.deliveries[":"+request.DeliveryID].Status != WebhookDeliveryStatusProcessed {
|
|
t.Fatalf(
|
|
"retry result=%+v updates=%d delivery=%+v",
|
|
result, len(repos.updates), store.deliveries[":"+request.DeliveryID],
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestGitHubWebhookReclaimsStaleReceivedDelivery(t *testing.T) {
|
|
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
|
store := &githubWebhookMemoryStore{deliveries: map[string]*WebhookDelivery{
|
|
":delivery-stale": {
|
|
DeliveryID: "delivery-stale", Event: "installation", Status: WebhookDeliveryStatusReceived,
|
|
ReceivedAt: now.Add(-webhookDeliveryStaleAfter - time.Second),
|
|
},
|
|
}}
|
|
service := NewGitHubWebhookService("webhook-secret", store, nil, nil, nil)
|
|
service.now = func() time.Time { return now }
|
|
payload := []byte(`{"action":"created","installation":{"id":999}}`)
|
|
result, err := service.Handle(
|
|
context.Background(), signedWebhookRequest("webhook-secret", "delivery-stale", "installation", payload),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Handle() error = %v", err)
|
|
}
|
|
if result.Duplicate || result.Status != WebhookDeliveryStatusIgnored {
|
|
t.Fatalf("Handle() = %+v", result)
|
|
}
|
|
}
|
|
|
|
func signedWebhookRequest(secret, deliveryID, event string, payload []byte) GitHubWebhookRequest {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write(payload)
|
|
return GitHubWebhookRequest{
|
|
DeliveryID: deliveryID,
|
|
Event: event,
|
|
Signature: "sha256=" + hex.EncodeToString(mac.Sum(nil)),
|
|
Payload: payload,
|
|
}
|
|
}
|