From 0ca7e186e77692a6ecacb6af11610f12c46fe2ab Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:30:40 +0000 Subject: [PATCH] fix(#6831): add authentication preflight to Jira poller The Jira poller silently succeeded with zero candidates when its configured credentials were invalid, because Jira treated unauthenticated requests as anonymous and returned empty results. This was indistinguishable from a genuinely quiet project. Add a GetMyself() call at the top of Poller.Run(), before JQL discovery, lock operations, or checkpoint writes. If the call fails (401/403) or returns an inactive account, Run returns a clear authentication error and exits non-zero without attempting any discovery. The error does not expose token or credential material. Tests cover auth failure (GetMyself error), inactive account, and successful preflight proceeding to normal discovery. Closes #6831 --- internal/jirapoll/poller.go | 12 +++++ internal/jirapoll/poller_test.go | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/internal/jirapoll/poller.go b/internal/jirapoll/poller.go index b10426a36b..ecf39e0c3e 100644 --- a/internal/jirapoll/poller.go +++ b/internal/jirapoll/poller.go @@ -107,6 +107,18 @@ func (p *Poller) Run(ctx context.Context) error { p.roleGroups = make(map[string][]string) p.roleGroupsChecked = make(map[string]bool) + // Auth preflight: verify credentials before any discovery, lock, or + // checkpoint operations. Without this, bad credentials cause Jira to + // treat the request as unauthenticated and return empty results — + // indistinguishable from a genuinely quiet project. + myself, err := p.client.GetMyself(ctx) + if err != nil { + return fmt.Errorf("authentication preflight failed: %w", err) + } + if !myself.Active { + return fmt.Errorf("authentication preflight failed: account %q is inactive", myself.AccountID) + } + // Step 1: Execute JQL to get candidate issues. candidates, err := p.searchCandidates(ctx) if err != nil { diff --git a/internal/jirapoll/poller_test.go b/internal/jirapoll/poller_test.go index 13589600d4..d91ccc5174 100644 --- a/internal/jirapoll/poller_test.go +++ b/internal/jirapoll/poller_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -80,6 +81,7 @@ func newMockClient() *mockClient { propertySetErr: make(map[string]error), statuses: make(map[string]jira.Status), statusErr: make(map[string]error), + myselfUser: &jira.User{AccountID: "poller-service-account", AccountType: "atlassian", Active: true}, } } @@ -296,6 +298,97 @@ func TestRunEmptyPoll(t *testing.T) { } } +// TestRun_AuthPreflightFailure verifies that Run returns a clear +// authentication error when GetMyself fails (e.g. 401/403), and that no +// JQL discovery is attempted — preventing the silent-zero-candidates +// failure mode described in #6831. +func TestRun_AuthPreflightFailure(t *testing.T) { + mc := newMockClient() + mc.myselfErr = fmt.Errorf("jira api: 401 unauthorized") + + dir := t.TempDir() + outputPath := filepath.Join(dir, "dispatches.json") + + p := newTestPoller(mc, nil, Options{ + TargetRepo: "acme/platform", + JiraBaseURL: "https://acme.atlassian.net", + JiraProject: "PROJ", + OutputPath: outputPath, + }) + + err := p.Run(context.Background()) + if err == nil { + t.Fatal("expected Run() to return an error when authentication preflight fails") + } + if got := err.Error(); !strings.Contains(got, "authentication preflight") { + t.Errorf("error = %q, want it to mention authentication preflight", got) + } + // SearchIssues must not have been called — the preflight short-circuits. + if mc.lastQuery != "" { + t.Errorf("SearchIssues was called with JQL %q; expected no JQL discovery after auth failure", mc.lastQuery) + } + // The error must not expose credential material (the mock error is a + // status-code string, so this validates the wrapping does not inject + // secrets). + if strings.Contains(err.Error(), "token") || strings.Contains(err.Error(), "password") { + t.Errorf("error exposes credential material: %q", err.Error()) + } +} + +// TestRun_AuthPreflightInactiveAccount verifies that an inactive Jira +// account is treated as an authentication failure. +func TestRun_AuthPreflightInactiveAccount(t *testing.T) { + mc := newMockClient() + mc.myselfUser = &jira.User{ + AccountID: "deactivated-service-account", + AccountType: "atlassian", + Active: false, + } + + p := newTestPoller(mc, nil, Options{ + TargetRepo: "acme/platform", + JiraBaseURL: "https://acme.atlassian.net", + JiraProject: "PROJ", + }) + + err := p.Run(context.Background()) + if err == nil { + t.Fatal("expected Run() to return an error for an inactive account") + } + if got := err.Error(); !strings.Contains(got, "inactive") { + t.Errorf("error = %q, want it to mention 'inactive'", got) + } + if mc.lastQuery != "" { + t.Errorf("SearchIssues was called; expected no JQL discovery after inactive-account check") + } +} + +// TestRun_AuthPreflightSuccess verifies that a valid, active account +// proceeds to JQL discovery as normal. +func TestRun_AuthPreflightSuccess(t *testing.T) { + mc := newMockClient() + // myselfUser is already set to an active user by newMockClient. + + dir := t.TempDir() + outputPath := filepath.Join(dir, "dispatches.json") + + p := newTestPoller(mc, nil, Options{ + TargetRepo: "acme/platform", + JiraBaseURL: "https://acme.atlassian.net", + JiraProject: "PROJ", + OutputPath: outputPath, + }) + + err := p.Run(context.Background()) + if err != nil { + t.Fatalf("Run() error: %v", err) + } + // Verify SearchIssues was called — preflight passed and discovery ran. + if mc.lastQuery == "" { + t.Error("SearchIssues was not called; expected JQL discovery after successful auth preflight") + } +} + func TestRunHappyPath_CommentWithSlashCommand(t *testing.T) { now := time.Now().Truncate(time.Second) mc := newMockClient()