Files
kandev/apps/backend/internal/jira/cloud_client_test.go
T
João Salavisa 54b98ed9eb feat(jira): add Jira integration for ticket browsing, import, and task linking (#705)
* feat(jira): add Jira integration for ticket browsing, import, and task linking

Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings
(site URL, email, API token or session cookie), encrypted secret storage,
and a REST client for tickets, projects, search, and transitions. A
background poller probes credentials on a 90s cadence and the keepalive
runs immediately on save so the UI's connected indicator updates without
delay.

The /jira page provides ticket browsing with filter pills, custom JQL,
saved views (including JQL), and configurable task-launch presets that
prefill TaskCreateDialog. JiraLinkButton on the task top bar links an
existing task to a ticket by prepending its key to the title; the ticket
popover surfaces details, transitions, and re-auth prompts on session
expiry. Topbar shows separate icon buttons for GitHub and Jira when each
is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address PR review feedback

- Token-based pagination (pageToken/isLast) replaces broken offset/total pagination
- Race-fix in service.clientFor: re-check cache after acquiring write lock
- sync.Mutex guards poller started/cancel state against concurrent Start/Stop
- Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test
- Async auth probe on SetConfig with detached context.Background goroutine
- Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel
- Propagate 4xx APIErrors from ListTransitions in GetTicket
- Distinguish "not found" from real errors in jiraSecretAdapter.Exists
- Log secret Exists errors instead of swallowing
- walkADF: handle softBreak (was only hardBreak despite the doc-comment)
- Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates
- ticket-row imports formatRelative/statusBadgeClass from common
- isFilterState type guard validates SavedView.filters shape
- task-presets readStorage preserves user's "cleared all" intent (returns [])
- task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits
- jira-api: caller `init` no longer overrides method/body via spread order
- jira-settings: disable Test button when no saved/typed secret

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): record auth probe failure even when probe ctx expires

- RecordAuthHealth now uses a fresh context.Background-derived ctx with a
  5s timeout for the UpdateAuthHealth DB write, so a probe that consumes
  its full 15s deadline still persists the failure (greptile P1).
- SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth
  manages its own probe timeout, so context.Background is enough.
- Add Service.SetProbeHook so tests can synchronize on probe completion;
  service_test waitForAuthProbe now blocks on the hook channel instead of
  time.Sleep polling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address coderabbit review feedback (round 3)

- url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths
  to harden against malicious keys ('../../foo', 'KEY?override=x').
- factoryHit on the test fixture is now sync/atomic.Int32; the async probe
  goroutine spawned by SetConfig races the test thread under -race.
- GetConfig logs Reveal failures (was silently dropped, leaving the UI to
  show "no expiry info" indistinguishably from a freshly-saved cookie).
- DeleteConfig logs secret-delete errors instead of swallowing them.
- clientFor distinguishes a secret-store failure from "not configured" so
  the UI doesn't render the configure-CTA for transient backend errors.
- Unify ErrNotConfigured wire code: HTTP and WebSocket both return
  "JIRA_NOT_CONFIGURED" via a shared constant.
- JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so
  re-linking a task replaces the prefix instead of stacking it.
- jira-settings clears testResult on save/delete so the alert reflects
  only the currently-saved credentials.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover Reveal error path in clientFor

Reveal-error case is distinct from "no config row" — the former wraps
the underlying error, the latter returns ErrNotConfigured. Without
this test the changed contract has no regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): route secret adapter Set through Exists

Treating every Get error as "not found" turned a transient DB error on
an existing row into a constraint-violation Create that masked the real
cause. Reuse Exists so the "secret not found:" prefix is the only path
that falls through to Create.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover jiraSecretAdapter.Set branching

Routing Set through Exists changed the contract: a transient DB error
in Get now propagates instead of cascading into a constraint-violation
Create. Add three tests so a future refactor cannot regress this without
CI catching it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): split Reveal errors and guard Save for new configs

- resolveCredentials now mirrors the clientFor split: a Reveal error
  surfaces as `read jira secret: %w` with a warn log instead of
  collapsing into "no token stored — paste one to test".
- disableSave gains the same missing-secret guard as disableTest so a
  brand-new config can no longer POST an empty secret and leave the
  workspace stuck with hasSecret=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:47:45 +01:00

254 lines
7.9 KiB
Go

package jira
import (
"context"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
s := httptest.NewServer(handler)
t.Cleanup(s.Close)
return s
}
func clientTo(ts *httptest.Server, method, secret string) *CloudClient {
cfg := &JiraConfig{
SiteURL: ts.URL,
Email: "user@example.com",
AuthMethod: method,
}
return NewCloudClient(cfg, secret)
}
func TestCloudClient_AuthHeaders_APIToken(t *testing.T) {
var gotAuth string
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"accountId":"a","displayName":"d","emailAddress":"e"}`))
})
c := clientTo(ts, AuthMethodAPIToken, "secrettoken")
res, err := c.TestAuth(context.Background())
if err != nil {
t.Fatalf("test auth: %v", err)
}
if !res.OK {
t.Fatalf("expected OK=true, got %+v", res)
}
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("user@example.com:secrettoken"))
if gotAuth != want {
t.Errorf("auth header = %q, want %q", gotAuth, want)
}
}
func TestCloudClient_AuthHeaders_SessionCookie_BareJWT(t *testing.T) {
var gotCookie string
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
gotCookie = r.Header.Get("Cookie")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"accountId":"a"}`))
})
// New flow: user pastes just the value of cloud.session.token /
// tenant.session.token from DevTools → Application → Cookies. The client
// wraps it under both names so a single paste works for password accounts
// and SSO tenants alike.
jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.sig"
c := clientTo(ts, AuthMethodSessionCookie, jwt)
if _, err := c.TestAuth(context.Background()); err != nil {
t.Fatalf("test auth: %v", err)
}
want := "cloud.session.token=" + jwt + "; tenant.session.token=" + jwt
if gotCookie != want {
t.Errorf("cookie header = %q, want %q", gotCookie, want)
}
}
func TestCloudClient_TestAuth_BadCreds_ReportsError(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"errorMessages":["bad creds"]}`))
})
c := clientTo(ts, AuthMethodAPIToken, "bad")
res, err := c.TestAuth(context.Background())
if err != nil {
t.Fatalf("test auth should not error on 401, got %v", err)
}
if res.OK {
t.Fatalf("expected OK=false, got %+v", res)
}
if !strings.Contains(res.Error, "401") {
t.Errorf("expected 401 in error, got %q", res.Error)
}
}
func TestCloudClient_GetTicket_ParsesADFDescription(t *testing.T) {
issueBody := `{
"key": "PROJ-42",
"fields": {
"summary": "Fix the thing",
"description": {
"type": "doc",
"content": [
{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]},
{"type":"paragraph","content":[{"type":"text","text":"line two"}]}
]
},
"status": {"id":"3","name":"In Progress"},
"project": {"key":"PROJ"},
"issuetype": {"name":"Bug"}
}
}`
transitionsBody := `{"transitions":[{"id":"11","name":"Start","to":{"id":"3","name":"In Progress"}}]}`
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42/transitions"):
_, _ = w.Write([]byte(transitionsBody))
case strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/PROJ-42"):
_, _ = w.Write([]byte(issueBody))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
})
c := clientTo(ts, AuthMethodAPIToken, "tok")
ticket, err := c.GetTicket(context.Background(), "PROJ-42")
if err != nil {
t.Fatalf("get ticket: %v", err)
}
if ticket.Summary != "Fix the thing" {
t.Errorf("summary: got %q", ticket.Summary)
}
if !strings.Contains(ticket.Description, "Hello world") {
t.Errorf("description missing first line: %q", ticket.Description)
}
if !strings.Contains(ticket.Description, "line two") {
t.Errorf("description missing second paragraph: %q", ticket.Description)
}
if ticket.StatusName != "In Progress" {
t.Errorf("status name: got %q", ticket.StatusName)
}
if len(ticket.Transitions) != 1 || ticket.Transitions[0].Name != "Start" {
t.Errorf("transitions: %+v", ticket.Transitions)
}
if ticket.URL != ts.URL+"/browse/PROJ-42" {
t.Errorf("url: got %q", ticket.URL)
}
}
func TestCloudClient_GetTicket_PlainStringDescription(t *testing.T) {
body := `{"key":"X-1","fields":{"summary":"s","description":"plain","status":{},"project":{},"issuetype":{}}}`
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/rest/api/3/issue/X-1/transitions") {
_, _ = w.Write([]byte(`{"transitions":[]}`))
return
}
_, _ = w.Write([]byte(body))
})
c := clientTo(ts, AuthMethodAPIToken, "t")
ticket, err := c.GetTicket(context.Background(), "X-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if ticket.Description != "plain" {
t.Errorf("description: got %q", ticket.Description)
}
}
func TestCloudClient_GetTicket_NonOK_ReturnsAPIError(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"errorMessages":["not found"]}`))
})
c := clientTo(ts, AuthMethodAPIToken, "t")
_, err := c.GetTicket(context.Background(), "NONE-1")
if err == nil {
t.Fatal("expected error")
}
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *APIError, got %T: %v", err, err)
}
if apiErr.StatusCode != 404 {
t.Errorf("status: got %d", apiErr.StatusCode)
}
}
func TestCloudClient_DoTransition_PostsBody(t *testing.T) {
var gotPath, gotBody, gotMethod string
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
buf := make([]byte, r.ContentLength)
_, _ = r.Body.Read(buf)
gotBody = string(buf)
w.WriteHeader(http.StatusNoContent)
})
c := clientTo(ts, AuthMethodAPIToken, "t")
if err := c.DoTransition(context.Background(), "PROJ-1", "21"); err != nil {
t.Fatalf("transition: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method: got %q", gotMethod)
}
if gotPath != "/rest/api/3/issue/PROJ-1/transitions" {
t.Errorf("path: got %q", gotPath)
}
if !strings.Contains(gotBody, `"id":"21"`) {
t.Errorf("body missing transition id: %q", gotBody)
}
}
func TestCloudClient_ListProjects(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/rest/api/3/project/search") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"values":[{"id":"1","key":"A","name":"Alpha"},{"id":"2","key":"B","name":"Beta"}]}`))
})
c := clientTo(ts, AuthMethodAPIToken, "t")
projects, err := c.ListProjects(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
if len(projects) != 2 || projects[0].Key != "A" || projects[1].Name != "Beta" {
t.Errorf("unexpected projects: %+v", projects)
}
}
func TestCloudClient_SiteURLTrailingSlash_Stripped(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
// If trailing slash wasn't stripped, we'd see "//rest/..."
if strings.HasPrefix(r.URL.Path, "//") {
t.Errorf("double slash in path: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{}`))
})
cfg := &JiraConfig{SiteURL: ts.URL + "/", Email: "e", AuthMethod: AuthMethodAPIToken}
c := NewCloudClient(cfg, "t")
if _, err := c.TestAuth(context.Background()); err != nil {
t.Fatalf("test: %v", err)
}
}
func TestExtractDescription_Nil(t *testing.T) {
if got := extractDescription(nil); got != "" {
t.Errorf("nil → %q", got)
}
}
func TestExtractDescription_UnknownShape(t *testing.T) {
if got := extractDescription(42); got != "" {
t.Errorf("int → %q", got)
}
}