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>
322 lines
10 KiB
Go
322 lines
10 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/netip"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type manifestResolverFunc func(context.Context, string, string) ([]netip.Addr, error)
|
|
|
|
func (f manifestResolverFunc) LookupNetIP(
|
|
ctx context.Context,
|
|
network, host string,
|
|
) ([]netip.Addr, error) {
|
|
return f(ctx, network, host)
|
|
}
|
|
|
|
func TestDeploymentAppManifestExactPolicyAndURLs(t *testing.T) {
|
|
resolver := manifestResolverFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if network != "ip" || host != "kandev.example" {
|
|
t.Fatalf("LookupNetIP() = %q, %q", network, host)
|
|
}
|
|
return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil
|
|
})
|
|
baseURL, err := ValidatePublicGitHubBaseURL(
|
|
context.Background(),
|
|
"https://KANDEV.example:443/",
|
|
resolver,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("ValidatePublicGitHubBaseURL() error = %v", err)
|
|
}
|
|
|
|
submission, err := BuildDeploymentAppManifest(ManifestOwnerUser, "octocat", baseURL)
|
|
if err != nil {
|
|
t.Fatalf("BuildDeploymentAppManifest() error = %v", err)
|
|
}
|
|
if submission.Revision != DeploymentAppManifestRevision {
|
|
t.Fatalf("Revision = %d, want %d", submission.Revision, DeploymentAppManifestRevision)
|
|
}
|
|
if submission.RegistrationURL != "https://github.com/settings/apps/new" {
|
|
t.Fatalf("RegistrationURL = %q", submission.RegistrationURL)
|
|
}
|
|
manifest := submission.Manifest
|
|
if manifest.Name == "" || len(manifest.Name) > 34 {
|
|
t.Fatalf("Name = %q, want non-empty GitHub-compatible name", manifest.Name)
|
|
}
|
|
if manifest.URL != "https://kandev.example" ||
|
|
manifest.RedirectURL != "https://kandev.example/api/v1/github/app/registration/callback" ||
|
|
!reflect.DeepEqual(manifest.CallbackURLs, []string{
|
|
"https://kandev.example/api/v1/github/personal-connection/callback",
|
|
}) ||
|
|
manifest.SetupURL != "https://kandev.example/api/v1/github/app/install/callback" ||
|
|
manifest.HookAttributes.URL != "https://kandev.example/api/v1/github/app/webhook" ||
|
|
!manifest.HookAttributes.Active {
|
|
t.Fatalf("manifest URLs = %+v", manifest)
|
|
}
|
|
if manifest.Public || !manifest.RequestOAuthOnInstall || manifest.SetupOnUpdate {
|
|
t.Fatalf("manifest flags = public:%v oauth:%v setup_on_update:%v",
|
|
manifest.Public, manifest.RequestOAuthOnInstall, manifest.SetupOnUpdate)
|
|
}
|
|
wantPermissions := map[string]string{
|
|
"actions": "read",
|
|
"administration": "read",
|
|
"checks": "read",
|
|
"contents": "write",
|
|
"issues": "write",
|
|
"members": "read",
|
|
"metadata": "read",
|
|
"pull_requests": "write",
|
|
"statuses": "read",
|
|
"workflows": "write",
|
|
}
|
|
if !reflect.DeepEqual(manifest.DefaultPermissions, wantPermissions) {
|
|
t.Fatalf("DefaultPermissions = %#v, want %#v", manifest.DefaultPermissions, wantPermissions)
|
|
}
|
|
wantEvents := []string{
|
|
"installation", "installation_repositories", "github_app_authorization", "push", "check_run",
|
|
}
|
|
if !reflect.DeepEqual(manifest.DefaultEvents, wantEvents) {
|
|
t.Fatalf("DefaultEvents = %#v, want %#v", manifest.DefaultEvents, wantEvents)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentAppManifestUsesPreallocatedRegistrationRoutes(t *testing.T) {
|
|
const registrationID = "11111111-1111-4111-8111-111111111111"
|
|
submission, err := BuildAppRegistrationManifest(AppRegistrationManifestRequest{
|
|
RegistrationID: registrationID,
|
|
OwnerType: ManifestOwnerOrganization, OwnerLogin: "acme",
|
|
PublicBaseURL: "https://kandev.example",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPrefix := "https://kandev.example/api/v1/github/app/registrations/" + registrationID
|
|
manifest := submission.Manifest
|
|
if manifest.RedirectURL != wantPrefix+"/manifest/callback" ||
|
|
manifest.SetupURL != wantPrefix+"/install/callback" ||
|
|
manifest.HookAttributes.URL != wantPrefix+"/webhook" ||
|
|
!reflect.DeepEqual(manifest.CallbackURLs, []string{wantPrefix + "/personal/callback"}) {
|
|
t.Fatalf("registration-specific manifest URLs = %+v", manifest)
|
|
}
|
|
if manifest.Public {
|
|
t.Fatal("manifest without explicit visibility is public")
|
|
}
|
|
}
|
|
|
|
func TestDeploymentAppManifestRequiresExplicitPublicVisibility(t *testing.T) {
|
|
request := AppRegistrationManifestRequest{
|
|
RegistrationID: "33333333-3333-4333-8333-333333333333",
|
|
OwnerType: ManifestOwnerUser, OwnerLogin: "octocat",
|
|
PublicBaseURL: "https://kandev.example",
|
|
}
|
|
privateSubmission, err := BuildAppRegistrationManifest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if privateSubmission.Manifest.Public {
|
|
t.Fatal("default manifest visibility is public")
|
|
}
|
|
request.Visibility = AppRegistrationVisibilityPublic
|
|
publicSubmission, err := BuildAppRegistrationManifest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !publicSubmission.Manifest.Public {
|
|
t.Fatal("explicit public manifest visibility was ignored")
|
|
}
|
|
request.Visibility = "marketplace"
|
|
if _, err := BuildAppRegistrationManifest(request); !errors.Is(err, ErrAppRegistrationVisibilityInvalid) {
|
|
t.Fatalf("invalid visibility error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentAppManifestOrganizationOwnerURL(t *testing.T) {
|
|
submission, err := BuildDeploymentAppManifest(
|
|
ManifestOwnerOrganization,
|
|
"kdlbs",
|
|
"https://kandev.example",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildDeploymentAppManifest() error = %v", err)
|
|
}
|
|
if submission.RegistrationURL != "https://github.com/organizations/kdlbs/settings/apps/new" {
|
|
t.Fatalf("RegistrationURL = %q", submission.RegistrationURL)
|
|
}
|
|
}
|
|
|
|
func TestDeploymentAppManifestRejectsInvalidOwner(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ownerType ManifestOwnerType
|
|
login string
|
|
}{
|
|
{name: "unknown owner", ownerType: "enterprise", login: "acme"},
|
|
{name: "missing login", ownerType: ManifestOwnerUser},
|
|
{name: "invalid login", ownerType: ManifestOwnerOrganization, login: "acme/widgets"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := BuildDeploymentAppManifest(tt.ownerType, tt.login, "https://kandev.example")
|
|
if !errors.Is(err, ErrDeploymentAppManifestOwnerInvalid) {
|
|
t.Fatalf("error = %v, want %v", err, ErrDeploymentAppManifestOwnerInvalid)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicGitHubBaseURLRejectsUnsafeOrigins(t *testing.T) {
|
|
resolver := manifestResolverFunc(func(_ context.Context, _, _ string) ([]netip.Addr, error) {
|
|
return []netip.Addr{netip.MustParseAddr("203.0.114.10")}, nil
|
|
})
|
|
tests := []string{
|
|
"http://kandev.example",
|
|
"https://user:pass@kandev.example",
|
|
"https://kandev.example/path",
|
|
"https://kandev.example?debug=true",
|
|
"https://kandev.example#fragment",
|
|
"https://127.0.0.1",
|
|
"https://10.0.0.1",
|
|
"https://169.254.1.2",
|
|
"https://[::1]",
|
|
"https://[fd00::1]",
|
|
"https://[100:0:0:1::1]",
|
|
"https://[2001::1]",
|
|
"https://[2001:2::1]",
|
|
"https://[2001:10::1]",
|
|
"https://[2001:db8::1]",
|
|
"https://[2002::1]",
|
|
"https://[3fff::1]",
|
|
"https://[5f00::1]",
|
|
"https://[fec0::1]",
|
|
}
|
|
for _, rawURL := range tests {
|
|
t.Run(rawURL, func(t *testing.T) {
|
|
_, err := ValidatePublicGitHubBaseURL(context.Background(), rawURL, resolver)
|
|
if err == nil {
|
|
t.Fatal("error = nil, want unsafe origin rejection")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicGitHubBaseURLAllowsIANAReachableSpecialPurposeAddresses(t *testing.T) {
|
|
for _, rawURL := range []string{
|
|
"https://192.0.0.9",
|
|
"https://192.0.0.10",
|
|
"https://[2001:1::1]",
|
|
"https://[2001:1::2]",
|
|
"https://[2001:1::3]",
|
|
"https://[2001:3::1]",
|
|
"https://[2001:4:112::1]",
|
|
"https://[2001:20::1]",
|
|
"https://[2001:30::1]",
|
|
} {
|
|
t.Run(rawURL, func(t *testing.T) {
|
|
canonical, err := ValidatePublicGitHubBaseURL(context.Background(), rawURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("ValidatePublicGitHubBaseURL() error = %v", err)
|
|
}
|
|
if canonical != rawURL {
|
|
t.Fatalf("canonical URL = %q, want %q", canonical, rawURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPublicGitHubBaseURLRejectsSiteLocalIPv6DNSResult(t *testing.T) {
|
|
resolver := manifestResolverFunc(func(_ context.Context, _, host string) ([]netip.Addr, error) {
|
|
if host != "site-local.example" {
|
|
t.Fatalf("host = %q", host)
|
|
}
|
|
return []netip.Addr{
|
|
netip.MustParseAddr("203.0.114.10"),
|
|
netip.MustParseAddr("fec0::1"),
|
|
}, nil
|
|
})
|
|
_, err := ValidatePublicGitHubBaseURL(context.Background(), "https://site-local.example", resolver)
|
|
if !errors.Is(err, ErrPublicGitHubBaseURLNotGlobal) {
|
|
t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLNotGlobal)
|
|
}
|
|
}
|
|
|
|
func TestPublicGitHubBaseURLRequiresEveryDNSResultToBeGlobal(t *testing.T) {
|
|
resolver := manifestResolverFunc(func(_ context.Context, _, host string) ([]netip.Addr, error) {
|
|
if host != "mixed.example" {
|
|
t.Fatalf("host = %q", host)
|
|
}
|
|
return []netip.Addr{
|
|
netip.MustParseAddr("203.0.114.10"),
|
|
netip.MustParseAddr("192.168.1.10"),
|
|
}, nil
|
|
})
|
|
_, err := ValidatePublicGitHubBaseURL(context.Background(), "https://mixed.example", resolver)
|
|
if !errors.Is(err, ErrPublicGitHubBaseURLNotGlobal) {
|
|
t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLNotGlobal)
|
|
}
|
|
}
|
|
|
|
func TestPublicGitHubBaseURLRejectsEmptyOrFailedDNS(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resolver manifestResolverFunc
|
|
}{
|
|
{
|
|
name: "empty result",
|
|
resolver: func(context.Context, string, string) ([]netip.Addr, error) {
|
|
return nil, nil
|
|
},
|
|
},
|
|
{
|
|
name: "lookup failure",
|
|
resolver: func(context.Context, string, string) ([]netip.Addr, error) {
|
|
return nil, errors.New("resolver details")
|
|
},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ValidatePublicGitHubBaseURL(
|
|
context.Background(),
|
|
"https://unresolved.example",
|
|
tt.resolver,
|
|
)
|
|
if !errors.Is(err, ErrPublicGitHubBaseURLUnresolvable) {
|
|
t.Fatalf("error = %v, want %v", err, ErrPublicGitHubBaseURLUnresolvable)
|
|
}
|
|
if err != nil && errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error unexpectedly exposed resolver details: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeploymentAppManifestFlowExpiresAfterOneHourAndRejectsReplay(t *testing.T) {
|
|
createdAt := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
|
|
expiresAt := DeploymentAppManifestFlowExpiresAt(createdAt)
|
|
if got := expiresAt.Sub(createdAt); got != time.Hour {
|
|
t.Fatalf("flow TTL = %v, want %v", got, time.Hour)
|
|
}
|
|
if err := ValidateDeploymentAppManifestFlow(expiresAt, nil, expiresAt.Add(-time.Nanosecond)); err != nil {
|
|
t.Fatalf("valid flow error = %v", err)
|
|
}
|
|
if err := ValidateDeploymentAppManifestFlow(expiresAt, nil, expiresAt); !errors.Is(
|
|
err,
|
|
ErrDeploymentAppManifestStateUnavailable,
|
|
) {
|
|
t.Fatalf("expired flow error = %v, want unavailable", err)
|
|
}
|
|
consumedAt := createdAt.Add(time.Minute)
|
|
if err := ValidateDeploymentAppManifestFlow(
|
|
expiresAt,
|
|
&consumedAt,
|
|
createdAt.Add(2*time.Minute),
|
|
); !errors.Is(err, ErrDeploymentAppManifestStateUnavailable) {
|
|
t.Fatalf("replayed flow error = %v, want unavailable", err)
|
|
}
|
|
}
|