From 0de4216ce655477679c39114ce2e4b381e3f37b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Sibiril?= <81782+aureliensibiril@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:17:52 +0200 Subject: [PATCH] Fix Heroku connection probe Accept header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heroku's connection-status probe used a static ProbeURL, which the generic probe issues with `Accept: application/json`. Heroku negotiates the API version through the Accept media type and returns 400 for an unversioned request, which doProbeRequest reads as "connected" -- so the probe never caught a revoked token (it only surfaced at the first ListAccounts). Probe via a probeHeroku closure that sends `Accept: application/vnd.heroku+json; version=3` instead. Verified live: a dead token returns 400 with application/json but 401 with the versioned header, which doProbeRequest correctly maps to rejected. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com> --- pkg/connector/provider/heroku.go | 13 ++++--- pkg/connector/provider/probe.go | 21 +++++++++++ pkg/connector/provider/probe_test.go | 52 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/pkg/connector/provider/heroku.go b/pkg/connector/provider/heroku.go index 69eae8f46..1b085bace 100644 --- a/pkg/connector/provider/heroku.go +++ b/pkg/connector/provider/heroku.go @@ -26,11 +26,14 @@ import ( func herokuRegistration() *Registration { return &Registration{ - Provider: coredata.ConnectorProviderHeroku, - DisplayName: "Heroku", - AuthURL: "https://id.heroku.com/oauth/authorize", - TokenURL: "https://id.heroku.com/oauth/token", - ProbeURL: "https://api.heroku.com/account", + Provider: coredata.ConnectorProviderHeroku, + DisplayName: "Heroku", + AuthURL: "https://id.heroku.com/oauth/authorize", + TokenURL: "https://id.heroku.com/oauth/token", + // Heroku requires the versioned Accept header; a plain ProbeURL GET + // (Accept: application/json) returns 400 and would read as connected, + // so probe via a closure that sends application/vnd.heroku+json. + Probe: probeHeroku, OAuth2Scopes: []string{"read"}, NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) { s, err := coredata.ConnectorSettings[coredata.HerokuConnectorSettings](conn) diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go index 816eb51aa..e7c5ca63d 100644 --- a/pkg/connector/provider/probe.go +++ b/pkg/connector/provider/probe.go @@ -31,6 +31,7 @@ import ( const ( anthropicAPIVersion = "2023-06-01" anthropicUsersProbeURL = "https://api.anthropic.com/v1/organizations/users?limit=1" + herokuAccountProbeURL = "https://api.heroku.com/account" linearGraphQLEndpoint = "https://api.linear.app/graphql" mondayGraphQLEndpoint = "https://api.monday.com/v2" posthogOrganizationPath = "/api/organizations/@current/" @@ -410,6 +411,26 @@ func probeAnthropic( return doProbeRequest(httpClient, req) } +func probeHeroku( + ctx context.Context, + httpClient *http.Client, + _ *coredata.Connector, +) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, herokuAccountProbeURL, nil) + if err != nil { + return fmt.Errorf("cannot create probe request: %w", err) + } + + // Heroku negotiates the API version through the Accept media type; the + // generic "application/json" the default probe sends yields 400 (not + // 401/403), which doProbeRequest would read as "connected" and mask a + // dead token. Send the versioned Accept so a revoked token surfaces as + // 401 (verified live: 400 with application/json, 401 with this header). + req.Header.Set("Accept", "application/vnd.heroku+json; version=3") + + return doProbeRequest(httpClient, req) +} + func probePostHog( ctx context.Context, httpClient *http.Client, diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go index 257199988..d30981a06 100644 --- a/pkg/connector/provider/probe_test.go +++ b/pkg/connector/provider/probe_test.go @@ -15,6 +15,8 @@ package provider import ( + "context" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -23,6 +25,12 @@ import ( "go.probo.inc/probo/pkg/coredata" ) +// probeRoundTripFunc lets a test capture the probe request and return a +// canned response without touching the network. +type probeRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f probeRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + func TestBuiltinRegistry_ProbeCoverage(t *testing.T) { t.Parallel() @@ -103,3 +111,47 @@ func TestBuildPostHogProbeURL(t *testing.T) { require.NoError(t, err) assert.Equal(t, "https://us.posthog.com/api/organizations/@current/", probeURL) } + +func TestProbeHeroku(t *testing.T) { + t.Parallel() + + // The fix's contract: probeHeroku must send Heroku's versioned Accept + // header — a plain "application/json" returns 400, which doProbeRequest + // reads as connected and masks a dead token — and it must map 401/403 to + // a rejection while letting 2xx pass. + cases := []struct { + name string + status int + wantReject bool + }{ + {"valid credential", http.StatusOK, false}, + {"revoked credential", http.StatusUnauthorized, true}, + {"forbidden credential", http.StatusForbidden, true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var gotAccept, gotURL string + + client := &http.Client{Transport: probeRoundTripFunc(func(r *http.Request) (*http.Response, error) { + gotAccept = r.Header.Get("Accept") + gotURL = r.URL.String() + + return &http.Response{StatusCode: tc.status, Body: http.NoBody, Header: make(http.Header)}, nil + })} + + err := probeHeroku(context.Background(), client, &coredata.Connector{Provider: coredata.ConnectorProviderHeroku}) + + assert.Equal(t, "application/vnd.heroku+json; version=3", gotAccept) + assert.Equal(t, "https://api.heroku.com/account", gotURL) + + if tc.wantReject { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +}