Files
kandev/apps/backend/internal/gitlab/service_test.go
T
João Salavisa 68aeefa296 feat(gitlab): add GitLab integration with MR / discussions support (#861)
* feat(gitlab): add GitLab integration with MR / discussions support

Adds a GitLab integration parallel to the GitHub one: PAT and glab CLI auth,
self-managed host configuration, MR feedback (approvals + discussions +
pipelines), and discussion reply / resolve so agents can address review
comments. The pr skill detects the host from git remote and creates a real
GitLab merge request via glab or REST.

Closes #820

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

* refactor(gitlab): address review feedback and tighten lint surface

- Add service_test.go covering ConfigureToken / ClearToken / ConfigureHost.
- Drop speculative MRWatch / TaskMR / ReviewWatch / IssueWatch / preset
  scaffolding that had no migrations or handlers behind it.
- Consolidate /approvals into one round-trip via fetchApprovals.
- Use http.DefaultClient in CheckHost (context already carries the deadline).
- Rename misleading id → configured in ConfigureToken.
- Introduce mrStateOpen constant so the lint goconst rule passes.

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

* fix(gitlab): address PR review feedback (CodeQL SSRF, races, atomicity)

- connectivity: parse host with url.Parse, validate http/https scheme,
  build probe URL via &url.URL{} so user-controlled host can't smuggle
  into the request — clears the CodeQL go/request-forgery alert.
- pat_client: guard cached username with sync.RWMutex; IsAuthenticated
  now propagates non-401/403 errors instead of masking transport
  failures; GetMRStatus skips pipelines query when HeadSHA / HeadBranch
  are both empty; parseNextLink drops cross-host links instead of
  returning bare absolute URLs that get() would double-prefix.
- service: findTokenSecret returns (bool, string, error) and all
  callers propagate the secrets.List error so a backend outage isn't
  mistaken for "token absent"; ConfigureHost persists to hostStore
  before mutating in-memory host + client + authMethod under one lock.
- repoclone: SSH clone URL falls back to ssh:// form when the host has
  a port (scp-style git@host:path can't carry one).
- gitlab/client_helpers: namespace fallback uses LastIndex("/") so
  subgroup paths (acme/team/squad/repo) are preserved.
- pr skill / plan doc: drop hardcoded --target-branch main, derive host
  from git remote instead of $CI_SERVER_URL, build curl payload with
  jq -n; fix /pipelines/:id/jobs → :pipeline_id typo.

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

* fix(gitlab): preserve host base path in CheckHost; broaden validation; test

- connectivity: extract validateHost so the parse / scheme / hostname /
  userinfo / opaque-URL checks each have a single home. Preserve the
  configured base path (TrimRight slash) so a host like
  https://gitlab.acme.corp/gitlab probes /gitlab/api/v4/version instead
  of dropping the prefix.
- connectivity_test: cover validateHost (default, https, http, trailing
  slash, subpath, missing scheme, ftp, empty host, userinfo) and
  CheckHost end-to-end (version endpoint, base-path preservation,
  scheme rejection, unreachable host).
- pr skill: drop the broken `glab repo view --json defaultBranch`
  branch — glab doesn't accept --json, so the lookup silently fell
  back to "main" every time. Fetch default_branch via the GitLab
  REST API directly using the PAT we already have.

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

* fix(gitlab): override filter defaults, refresh auth uncached, mock pipelines

- appendFilter: Del before re-Add so user-supplied filter keys actually
  override caller defaults (e.g. state=closed swaps the seeded state=opened
  instead of appending a second value GitLab silently ignored).
- IsAuthenticated: probe /user directly instead of routing through the
  username cache; a token revoked upstream now surfaces as not-connected
  immediately. Cache is opportunistically refreshed on success.
- MockClient.GetMRFeedback: include pipeline state in HasIssues, matching
  the real PATClient behavior so test flows can't miss a broken MR.
- pr skill: document `glab mr note create --reply <id>` for replies (bare
  `glab mr note` opens a new thread); fix host-extraction block to handle
  ssh://host:2222/team/repo.git without leaking the port into PROJECT.

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

* fix(gitlab): guard mock pipelines on empty MR head; preserve HTTPS port in pr skill

- MockClient.GetMRFeedback now mirrors PATClient: skip the pipelines
  fetch when HeadSHA and HeadBranch are both empty. The mock'\''s
  ListPipelines ignores its branch argument and returns every pipeline
  seeded under the project, so without this guard a brand-new MR would
  inherit a sibling MR'\''s failing pipeline and flip HasIssues to true.
  Tests cover both the empty-head and head-present cases.
- pr skill: derive HOST scheme-aware so https://host:8443 keeps the port
  (HTTPS API endpoint), ssh://host:2222 strips it (different transport),
  and scp-style git@host stays portless. Avoids silently re-routing
  self-managed API calls to the default 443.

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

* fix(gitlab): export SeedPipelines; mock GetMRStatus parity; drain bodies; test cache hit

- MockClient: add exported SeedPipelines(project, iid, []) alongside the
  other Seed* methods; mirror PATClient.GetMRStatus by populating
  PipelineState / jobs counts via summarizePipelines, guarded on a
  non-empty head ref. Brings mock parity with the real client so any
  future test seeding pipelines sees the same MRStatus shape.
- PATClient HTTP plumbing: drain resp.Body via io.Copy(io.Discard, ...)
  when result == nil in both getWithTotal and doWrite, so HTTP/1.1 can
  reuse the connection instead of opening a new TCP socket per call.
- TestPATClient_GetAuthenticatedUser: assert calls == 1 after the second
  GetAuthenticatedUser hit so the cache contract is actually verified
  (a non-caching implementation would have passed the prior version).

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

* fix(gitlab): surface connection errors, align MR pipeline guards, fix mock seed key

- service.GetStatus now captures the IsAuthenticated error into a new
  Status.ConnectionError field (empty on healthy / 401-403, populated on
  network / 5xx / parse failures). Frontend GitLabStatus + settings page
  render a distinct "Unreachable" badge + alert so users don't delete a
  still-valid token during a transient GitLab outage.
- PATClient.GetMRFeedback now guards on HeadSHA || HeadBranch, matching
  GetMRStatus and MockClient.GetMRFeedback (which had been updated in an
  earlier round). The three call sites were inconsistent — a mock test
  with HeadBranch-but-no-SHA would pass while the real client silently
  dropped pipelines.
- MockClient.pipelines is now keyed by project alone (was
  map[mockMRKey][]Pipeline). SeedPipelines takes (project, []) so
  successive seeds for the same project deterministically overwrite,
  instead of leaving multiple entries that ListPipelines would resolve
  in Go's unspecified map-iteration order.
- Tests: TestService_GetStatus_{PopulatesConnectionErrorOn5xx,
  ClearsConnectionErrorOn401} cover the new error surface;
  TestMockClient_SeedPipelines_OverwritesByProject locks the key change.

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

* fix(gitlab): tighten validateHost; init Status scopes; close token-host race

- validateHost: drop the parsed.Opaque != "" branch — Go only sets
  Opaque for opaque URIs, which have an empty Host the prior check
  already rejects. Narrow the userinfo error to mention the actual
  user:password@host shape so the message matches what users see.
- service.GetStatus: initialize RequiredScopes: []string{} on the
  nil-client defensive branch so the JSON payload keeps the
  always-an-array contract declared on the TypeScript side. The branch
  is effectively unreachable (every Provide path returns at least a
  NoopClient) but the empty-slice fallback costs nothing.
- service.ConfigureToken: build the installed client inside the write
  lock using the up-to-date s.host instead of the pre-probe snapshot.
  A concurrent ConfigureHost between RUnlock and Lock used to leave
  s.client pointing at the prior host while s.host advertised the new
  one — every subsequent API call would silently target the wrong
  instance until the next reconfigure.

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

* fix(gitlab): drop MR-commit stats fields; rename AuthorName; log post-lock host

- MRCommitInfo: drop Additions / Deletions / FilesChanged. GitLab's
  /merge_requests/:iid/commits endpoint doesn't return a stats object,
  so those fields were always 0 — better to omit them than feed callers
  silently-wrong numbers. Rename AuthorUsername to AuthorName since the
  API returns a display name, not a login handle. Doc-comment records
  the per-commit endpoint to use if a future revision wants real stats.
- ListMRCommits: drop the Stats anonymous struct from the JSON decode
  target now that the destination fields are gone.
- service.ConfigureToken: capture installedHost inside the write lock
  and log that value. The previous log used the pre-validation host
  snapshot, so a concurrent ConfigureHost between snapshot and lock
  would leave the log entry referring to a stale value while the
  client itself was correctly built against the new host.

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

* fix(gitlab): encode subgroup paths; sentinel-route errors; mock approval parity

- pat_client.SearchGroupProjects now encodes the group path through a new
  groupRef helper that %2F-substitutes slashes (mirrors projectRef). Subgroup
  paths like "acme/team" used to land at /groups/acme/team/projects, which
  GitLab routed to the parent group only and returned 404 for. Subgroups now
  work end-to-end.
- service.ErrInvalidToken sentinel + controller errors.Is routing: token
  validation failures now wrap a package-level sentinel and the HTTP layer
  switches on it via errors.Is, replacing the fragile strings.Contains
  prefix check that would silently fall through to 500 if the message ever
  changed. Coverage: TestService_ConfigureToken_WrapsErrInvalidToken.
- MockClient gains seedable approvals: SeedApprovals(project, iid, list,
  required) backs a dedicated approvals + requiredApprovals map.
  ListMRApprovals, GetMRFeedback, and GetMRStatus all consume them so
  ApprovalState / ApprovalCount / RequiredApprovals match what the real
  PATClient.GetMRStatus computes. Coverage:
  TestMockClient_GetMRStatus_UsesSeededApprovals.
- ClearToken: same host-snapshot race ConfigureToken was fixed for last
  round — NewClient now runs under the write lock against s.host rather
  than a pre-acquired snapshot.
- CheckHost: drain resp.Body before close so http.DefaultClient's keep-alive
  pool can reuse the connection for the next probe.

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

* fix(gitlab): full-path projectRef round-trip; unlock during glab probe

- splitFullReference now returns (namespace, fullPath) where fullPath is
  the namespace-qualified project path ("group/sub/project") instead of
  the last segment alone. Callers can pass MR.ProjectPath straight to
  projectRef for round-trip API calls (GetMR, GetMRFeedback, etc.) —
  matches GitLab's own path_with_namespace convention. Existing test
  cases updated.
- ClearToken: build the fallback client OUTSIDE the write lock so
  NewClient's glab subprocess (bounded by glabAuthTimeout = 10s)
  doesn't freeze every concurrent GetStatus / GetMRFeedback /
  Client() RLock reader for the duration. Brief WLock now only wraps
  the field swap. Tradeoff with the concurrent-ConfigureHost race is
  documented in-code: narrow stale-host window vs. 10s of blocked
  readers, and the next reconfigure resolves it.
- New regression test TestPATClient_SearchGroupProjects_EncodesSubgroupSlash
  asserts the request URL is /groups/acme%2Fteam/projects so a future
  refactor that swapped groupRef back to a bare url.PathEscape would
  fail visibly instead of silently 404ing subgroup lookups.

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

* fix(gitlab): equality-guard ClearToken's client swap against ConfigureHost race

ClearToken builds the fallback client outside the write lock to avoid
blocking RLock readers for up to 10s while glab auth-probes. The
swap-only critical section now compares s.host against the pre-probe
snapshot before assigning — if a concurrent ConfigureHost moved the
host underneath us, we leave its (newer, correctly-targeted) client
alone instead of overwriting with our stale-host build.

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

* fix(web): add GitLab to the settings integrations sidebar

The /settings/integrations/gitlab page existed and the index card listed
GitLab alongside the other integrations, but the settings sidebar nav
(IntegrationsNavGroup) only listed GitHub / Jira / Linear / Slack. The
page was unreachable through the standard sidebar.

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

* feat(gitlab): task-MR topbar surface + DB-backed task↔MR store

Phase 1 of the GitLab UI build-out: brings the MR side of the task topbar
to parity with the existing GitHub PR button.

Backend
- gitlab_task_mrs table + Store wiring (UpsertTaskMR,
  ListTaskMRsByTask, ListTaskMRsByWorkspaceID, DeleteTaskMR), uniqued
  on (task_id, repository_id, project_path, mr_iid) for multi-repo.
- service.SyncTaskMR folds a live GetMRStatus probe into an upsert;
  ListTaskMRsByTask / ListTaskMRsByWorkspace expose the rows.
- Three HTTP endpoints: GET /workspaces/:id/task-mrs, GET
  /tasks/:taskID/mrs, POST /tasks/:taskID/mrs/sync.
- initGitLabService now takes a *db.Pool and constructs gitlab.Store.
  Failures non-fatal: integration keeps working without the table.

Frontend
- TaskMR type + listWorkspaceTaskMRs / listTaskMRs / syncTaskMR API.
- lib/state/slices/gitlab slice (taskMRs.byTaskId) wired through
  default-state, store.ts AppState, and slices/index exports.
- hooks/domains/gitlab/use-task-mr.ts: useWorkspaceMRs (fetch-on-
  workspace-switch) and useTaskMRs (per-task selector).
- components/gitlab/mr-topbar-button.tsx — single/multi MR variants,
  status icons mirroring pr-task-icon priority order.
- task-top-bar.tsx renders PRTopbarButton and MRTopbarButton side-by-
  side; each returns null when empty, so the union handles GitHub-only,
  GitLab-only, and multi-repo tasks without an explicit switch.
- kanban-board.tsx hydrates useWorkspaceMRs alongside useWorkspacePRs.

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

* feat(gitlab): add /gitlab page for browsing user MRs and issues

Adds the GitLab counterpart to /github so users can browse their own
merge requests and issues from inside kandev, parallel to the existing
GitHub "My GitHub" page.

Backend
- /api/v1/gitlab/user/mrs and /user/issues — paginated wrappers around
  the existing SearchMRsPaged / ListIssuesPaged client methods.
  paginationFromQuery clamps page/per_page the same way as the client.

Frontend
- /gitlab SSR page + client with MR/Issue tabs, scope filters
  (assigned / created / review-requested), and a free-text search box
  that falls back to raw GitLab query-string for power users.
- searchUserMRs / searchUserIssues API helpers + MR / Issue / *SearchPage
  types in lib/types/gitlab.
- useGitLabAvailable hook (probes /status + refreshes on window focus)
  feeds the integrations menu so the GitLab link only shows when a
  token is configured or auth is green.

Tests
- task-top-bar.test mocks the new MRTopbarButton so renders no longer
  pick up useAppStore without a StateProvider.
- integrations-menu.test wires the new useGitLabAvailable mock and
  gitlabReady prop into mockAvailability / getAvailableIntegrationLinks
  fixtures.

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

* fix(gitlab): stable empty array in useTaskMRs to avoid infinite re-render

useTaskMRs returned `state.taskMRs.byTaskId[taskId] ?? []` — that fresh
literal made the zustand selector produce a new reference on every
render when the task had no MRs, triggering an infinite re-render loop
in MRTopbarButton that crashed the task page with a Next.js client-side
exception.

The page never mounted session-chat, so Docker E2E tests that drive
`/t/:id` (slow-bootstrap progress, external-stop resume, environment
reset) all timed out waiting for the chat panel.

Mirror the GitHub side's `EMPTY_PRS` pattern: hoist a module-level
`EMPTY_MRS = []` and return it for the empty case.

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

* fix(gitlab): address PR review — cache header, in-flight cancel, tests

- Top-level `cache: "no-store"` in 5 sites (use-task-mr.ts, gitlab-api.ts,
  gitlab-page-client.tsx). The earlier `init: { cache: "no-store" }`
  placement was silently overwritten by fetchJson with undefined, so probes
  hit the browser cache instead.
- useWorkspaceMRs now invalidates in-flight responses and calls
  resetTaskMRs when the workspace becomes null, so the previous workspace's
  MRs don't linger after sign-out / workspace switch.
- Drop unpopulated UnresolvedThreads/DiscussionCount from TaskMR (model,
  schema, TS type). SyncTaskMR's source MRStatus never populated them and
  no surface reads them; the dropped columns are skipped via an explicit
  projection in store.go so existing dev DBs keep binding.
- Extract MRList/IssueList inline fetches in /gitlab into useGitLabUserMRs
  / useGitLabUserIssues — matches CLAUDE.md "never fetch in components".
- Tests for store (UpsertTaskMR, composite key, workspace-JOIN isolation,
  ordering, Delete), service (SyncTaskMR mock round-trip + field mapping +
  store-unset error, ListTaskMRsByWorkspace JOIN isolation, ListTaskMRsByTask),
  pat_client (ListIssuesPaged X-Total), gitlab-api (5 new fns), gitlab-slice
  (setTaskMR composite key), and the use-task-mr / use-user-search hooks.
- pr SKILL.md: skip note for steps 5–6 on GitLab remotes.

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

* fix(gitlab): only wrap 401/403 probe errors as ErrInvalidToken

Network failures, 5xx, and DNS errors during `ConfigureToken` previously
surfaced to the user as HTTP 400 "invalid token: …" — during a GitLab
outage a user with a valid token might delete it assuming it was rejected.
Now only `*APIError` with 401/403 is mapped to `ErrInvalidToken`; other
errors propagate unwrapped so the controller routes them to "GitLab
unreachable" instead.

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-05-23 23:17:16 +01:00

551 lines
18 KiB
Go

package gitlab
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeSecretManager is an in-memory SecretManager + SecretProvider used to
// drive ConfigureToken / ClearToken without touching the real secret store.
type fakeSecretManager struct {
items []*SecretListItem
values map[string]string
createErr error
updateErr error
deleteErr error
createCalls int
updateCalls int
deleteCalls int
}
func newFakeSecretManager() *fakeSecretManager {
return &fakeSecretManager{values: map[string]string{}}
}
func (f *fakeSecretManager) List(context.Context) ([]*SecretListItem, error) {
return f.items, nil
}
func (f *fakeSecretManager) Reveal(_ context.Context, id string) (string, error) {
return f.values[id], nil
}
func (f *fakeSecretManager) Create(_ context.Context, name, value string) (string, error) {
f.createCalls++
if f.createErr != nil {
return "", f.createErr
}
id := "id-" + name
f.items = append(f.items, &SecretListItem{ID: id, Name: name, HasValue: true})
f.values[id] = value
return id, nil
}
func (f *fakeSecretManager) Update(_ context.Context, id, value string) error {
f.updateCalls++
if f.updateErr != nil {
return f.updateErr
}
f.values[id] = value
return nil
}
func (f *fakeSecretManager) Delete(_ context.Context, id string) error {
f.deleteCalls++
if f.deleteErr != nil {
return f.deleteErr
}
delete(f.values, id)
out := f.items[:0]
for _, it := range f.items {
if it.ID != id {
out = append(out, it)
}
}
f.items = out
return nil
}
// userHandler returns an http.Handler that responds to /api/v4/user as if it
// were a real GitLab. accept controls whether a request is treated as
// authorized.
func userHandler(accept func(token string) bool) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) {
if !accept(r.Header.Get("PRIVATE-TOKEN")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"username":"alice"}`))
})
mux.HandleFunc("/api/v4/version", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"version":"16.0.0"}`))
})
return mux
}
func newServiceFixture(t *testing.T, handler http.Handler) (*Service, *httptest.Server, *fakeSecretManager) {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
log := newTestLogger(t)
mgr := newFakeSecretManager()
svc := NewService(srv.URL, NewNoopClient(srv.URL), AuthMethodNone, mgr, log)
svc.SetSecretManager(mgr)
return svc, srv, mgr
}
func TestService_ConfigureToken_ValidTokenCreatesSecretAndSwapsClient(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
return token == "good-token"
}))
if err := svc.ConfigureToken(context.Background(), "good-token"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
if mgr.createCalls != 1 {
t.Errorf("create calls = %d, want 1", mgr.createCalls)
}
if mgr.updateCalls != 0 {
t.Errorf("update calls = %d, want 0 on first configure", mgr.updateCalls)
}
if _, ok := svc.Client().(*PATClient); !ok {
t.Errorf("client = %T, want *PATClient after configure", svc.Client())
}
status, _ := svc.GetStatus(context.Background())
if status.AuthMethod != AuthMethodPAT {
t.Errorf("auth_method = %q, want pat", status.AuthMethod)
}
}
func TestService_ConfigureToken_InvalidTokenReturnsErrorAndKeepsNoop(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return false }))
err := svc.ConfigureToken(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error for invalid token")
}
if !strings.Contains(err.Error(), "invalid token") {
t.Errorf("err = %v, want 'invalid token' prefix", err)
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0 — secret must not be written on invalid token", mgr.createCalls)
}
if _, ok := svc.Client().(*NoopClient); !ok {
t.Errorf("client = %T, want *NoopClient (unchanged after failure)", svc.Client())
}
}
func TestService_ConfigureToken_PreExistingTokenCallsUpdateNotCreate(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(token string) bool {
return token == "new-token"
}))
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
mgr.values["id-GITLAB_TOKEN"] = "old-token"
if err := svc.ConfigureToken(context.Background(), "new-token"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
if mgr.updateCalls != 1 {
t.Errorf("update calls = %d, want 1", mgr.updateCalls)
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0 when secret exists", mgr.createCalls)
}
if mgr.values["id-GITLAB_TOKEN"] != "new-token" {
t.Errorf("stored token = %q, want new-token", mgr.values["id-GITLAB_TOKEN"])
}
}
func TestService_ConfigureToken_RejectsEmpty(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
err := svc.ConfigureToken(context.Background(), " ")
if err == nil {
t.Fatal("expected error for empty token")
}
if mgr.createCalls != 0 {
t.Errorf("create calls = %d, want 0", mgr.createCalls)
}
}
func TestService_ConfigureToken_ErrorsWhenSecretManagerMissing(t *testing.T) {
log := newTestLogger(t)
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
err := svc.ConfigureToken(context.Background(), "anything")
if err == nil {
t.Fatal("expected error when secret manager not configured")
}
}
func TestService_ClearToken_NoSecretIsNoOp(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
t.Setenv("GITLAB_TOKEN", "") // make sure factory falls through to noop
if err := svc.ClearToken(context.Background()); err != nil {
t.Fatalf("ClearToken err = %v on empty store, want nil", err)
}
if mgr.deleteCalls != 0 {
t.Errorf("delete calls = %d, want 0", mgr.deleteCalls)
}
}
func TestService_ClearToken_DeletesSecretAndRebuildsClient(t *testing.T) {
svc, _, mgr := newServiceFixture(t, userHandler(func(string) bool { return true }))
t.Setenv("GITLAB_TOKEN", "")
mgr.items = []*SecretListItem{{ID: "id-GITLAB_TOKEN", Name: "GITLAB_TOKEN", HasValue: true}}
mgr.values["id-GITLAB_TOKEN"] = "old"
if err := svc.ClearToken(context.Background()); err != nil {
t.Fatalf("ClearToken err = %v", err)
}
if mgr.deleteCalls != 1 {
t.Errorf("delete calls = %d, want 1", mgr.deleteCalls)
}
// After clearing the only PAT, the factory should fall back to noop
// (glab CLI may or may not be on the test host, so accept both).
if _, isPAT := svc.Client().(*PATClient); isPAT {
t.Errorf("client = *PATClient after clear, want non-PAT fallback")
}
}
func TestService_ConfigureHost_NormalisesAndPersists(t *testing.T) {
svc, srv, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
if err := svc.ConfigureHost(context.Background(), srv.URL+"/"); err != nil {
t.Fatalf("ConfigureHost err = %v", err)
}
if svc.Host() != srv.URL {
t.Errorf("host = %q, want %q (trailing slash trimmed)", svc.Host(), srv.URL)
}
}
func TestService_ConfigureHost_RejectsMissingScheme(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
err := svc.ConfigureHost(context.Background(), "gitlab.example.com")
if err == nil {
t.Fatal("expected error for host without scheme")
}
if !strings.Contains(err.Error(), "scheme") {
t.Errorf("err = %v, want mention of scheme", err)
}
}
func TestService_ConfigureHost_UnreachableHostReturnsError(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
// 192.0.2.0/24 is reserved by RFC 5737 for documentation; connecting
// to it deterministically fails (no route / no listener).
err := svc.ConfigureHost(context.Background(), "http://192.0.2.1:1/")
if err == nil {
t.Fatal("expected unreachable error")
}
if !strings.Contains(err.Error(), "unreachable") {
t.Errorf("err = %v, want 'unreachable' in message", err)
}
}
func TestService_GetStatus_ReportsAuthenticatedAfterConfigure(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(token string) bool {
return token == "ok"
}))
if err := svc.ConfigureToken(context.Background(), "ok"); err != nil {
t.Fatalf("ConfigureToken err = %v", err)
}
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if !status.Authenticated {
t.Error("status.Authenticated = false, want true")
}
if status.Username != "alice" {
t.Errorf("status.Username = %q, want alice", status.Username)
}
if !status.TokenConfigured {
t.Error("status.TokenConfigured = false, want true")
}
if status.ConnectionError != "" {
t.Errorf("status.ConnectionError = %q, want empty on healthy probe", status.ConnectionError)
}
}
// Regression: a 5xx (or any non-401/403 error) from the IsAuthenticated probe
// must surface as ConnectionError so the UI can render "unreachable" instead
// of "not connected" — the prior code dropped the error and made transient
// outages look like a bad token.
func TestService_GetStatus_PopulatesConnectionErrorOn5xx(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
svc, _, _ := newServiceFixture(t, mux)
// Force the service to actually call the upstream by swapping in a
// PATClient pointed at our fake-failing server.
svc.mu.Lock()
svc.client = NewPATClient(svc.host, "tok")
svc.authMethod = AuthMethodPAT
svc.mu.Unlock()
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if status.Authenticated {
t.Error("Authenticated = true on 5xx, want false")
}
if status.ConnectionError == "" {
t.Error("ConnectionError empty on 5xx, want populated (a transport failure must distinguish itself from 'not connected')")
}
}
// Counterpart: a 401 is a clean "bad token" signal — IsAuthenticated returns
// (false, nil) — and must leave ConnectionError empty so the UI doesn't
// render an "unreachable" warning over a legitimately-rejected token.
func TestService_GetStatus_ClearsConnectionErrorOn401(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
})
svc, _, _ := newServiceFixture(t, mux)
svc.mu.Lock()
svc.client = NewPATClient(svc.host, "bad")
svc.authMethod = AuthMethodPAT
svc.mu.Unlock()
status, err := svc.GetStatus(context.Background())
if err != nil {
t.Fatalf("GetStatus err = %v", err)
}
if status.Authenticated {
t.Error("Authenticated = true on 401, want false")
}
if status.ConnectionError != "" {
t.Errorf("ConnectionError = %q on 401, want empty (401 is a clean not-authenticated signal, not a transport failure)", status.ConnectionError)
}
}
// ConfigureToken must wrap ErrInvalidToken so the controller can route the
// failure to HTTP 400 via errors.Is, instead of string-matching on the
// error message. Covers both the empty-token path and the failed-probe path.
func TestService_ConfigureToken_WrapsErrInvalidToken(t *testing.T) {
t.Run("empty token", func(t *testing.T) {
log := newTestLogger(t)
svc := NewService("", NewNoopClient(""), AuthMethodNone, nil, log)
svc.SetSecretManager(newFakeSecretManager())
err := svc.ConfigureToken(context.Background(), " ")
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
}
})
t.Run("rejected by probe", func(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return false }))
err := svc.ConfigureToken(context.Background(), "bad-token")
if !errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, want errors.Is ErrInvalidToken", err)
}
})
}
// Regression: a 5xx (or any non-401/403) error from the probe must NOT be
// wrapped as ErrInvalidToken — that would surface as HTTP 400 "invalid token"
// to a user during a GitLab outage and risk them deleting a valid token.
func TestService_ConfigureToken_DoesNotWrapTransportErrorAsErrInvalidToken(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
svc, _, _ := newServiceFixture(t, mux)
err := svc.ConfigureToken(context.Background(), "valid-but-server-is-down")
if err == nil {
t.Fatal("expected error when probe hits 5xx")
}
if errors.Is(err, ErrInvalidToken) {
t.Errorf("err = %v, must NOT be ErrInvalidToken on 5xx (would mislead user during an outage)", err)
}
}
// --- Task ↔ MR association ---
// withTaskMRStore wires a temporary GitLab Store onto the service so the
// task-MR methods exercise real SQLite. Returns the store for direct
// inspection.
func withTaskMRStore(t *testing.T, svc *Service) *Store {
t.Helper()
store := newTestStore(t)
svc.SetStore(store)
return store
}
// swapToMock replaces the service's client with an in-memory MockClient that
// tests can pre-seed with MRs. Returns the mock for further seeding.
func swapToMock(t *testing.T, svc *Service) *MockClient {
t.Helper()
mock := NewMockClient(svc.Host())
svc.mu.Lock()
svc.client = mock
svc.mu.Unlock()
return mock
}
func TestService_SyncTaskMR_UpsertsFromMockClient(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-1", "ws-1")
mock.SeedMR("acme/api", &MR{
IID: 42,
Title: "feat: thing",
HeadBranch: "feat/x",
BaseBranch: "main",
State: mrStateOpen,
WebURL: "https://gitlab.example/acme/api/-/merge_requests/42",
HeadSHA: "abc123",
CreatedAt: time.Now().UTC(),
})
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 42)
if err != nil {
t.Fatalf("SyncTaskMR err = %v", err)
}
if row.MRTitle != "feat: thing" || row.HeadBranch != "feat/x" || row.BaseBranch != "main" {
t.Errorf("unexpected upserted row: %+v", row)
}
if row.LastSyncedAt == nil {
t.Error("LastSyncedAt not stamped")
}
// Round-trip through the store: the row must be readable by ID.
stored, _ := store.ListTaskMRsByTask(context.Background(), "task-1")
if len(stored) != 1 || stored[0].MRTitle != "feat: thing" {
t.Fatalf("store roundtrip failed: %+v", stored)
}
}
func TestService_SyncTaskMR_PreservesPipelineJobsMapping(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
mock.SeedMR("acme/api", &MR{IID: 7, HeadSHA: "deadbeef", HeadBranch: "f", State: mrStateOpen})
// Two finished pipelines: one all-pass, one with a failure.
finished := time.Now().UTC()
mock.SeedPipelines("acme/api", []Pipeline{
{ID: 1, Status: "success", SHA: "deadbeef", JobsTotal: 3, JobsPassing: 3, FinishedAt: &finished},
})
row, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 7)
if err != nil {
t.Fatalf("sync err = %v", err)
}
if row.PipelineJobsTotal != 3 || row.PipelineJobsPass != 3 {
t.Errorf("pipeline jobs mapping wrong: total=%d pass=%d (want 3/3) — field-name regression",
row.PipelineJobsTotal, row.PipelineJobsPass)
}
}
func TestService_SyncTaskMR_ReturnsErrWhenMRNotFound(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
withTaskMRStore(t, svc)
swapToMock(t, svc)
_, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 999)
if err == nil {
t.Fatal("expected error for unseeded MR")
}
}
func TestService_SyncTaskMR_ErrorWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
swapToMock(t, svc) // no SetStore — store remains nil
_, err := svc.SyncTaskMR(context.Background(), "task", "", "p", 1)
if err == nil {
t.Fatal("expected error when store is nil")
}
if !strings.Contains(err.Error(), "store") {
t.Errorf("err = %v, want mention of store", err)
}
}
func TestService_ListTaskMRsByWorkspace_EmptyWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 0 {
t.Errorf("got %d rows, want 0 when store is nil", len(got))
}
}
func TestService_ListTaskMRsByWorkspace_ScopesByWorkspaceJoin(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-a", "ws-1")
seedTask(t, store, "task-b", "ws-2")
mock.SeedMR("p1", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
mock.SeedMR("p2", &MR{IID: 2, HeadBranch: "y", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
if _, err := svc.SyncTaskMR(context.Background(), "task-a", "", "p1", 1); err != nil {
t.Fatalf("sync a: %v", err)
}
if _, err := svc.SyncTaskMR(context.Background(), "task-b", "", "p2", 2); err != nil {
t.Fatalf("sync b: %v", err)
}
got, err := svc.ListTaskMRsByWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 1 || len(got["task-a"]) != 1 {
t.Fatalf("ws-1 result = %+v, want task-a with one MR", got)
}
if _, leaked := got["task-b"]; leaked {
t.Error("ws-2's MR leaked into ws-1 result — workspace JOIN broken")
}
}
func TestService_ListTaskMRsByTask_DelegatesToStore(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
store := withTaskMRStore(t, svc)
mock := swapToMock(t, svc)
seedTask(t, store, "task-1", "ws-1")
mock.SeedMR("acme/api", &MR{IID: 1, HeadBranch: "x", BaseBranch: "main", State: mrStateOpen, CreatedAt: time.Now().UTC()})
if _, err := svc.SyncTaskMR(context.Background(), "task-1", "", "acme/api", 1); err != nil {
t.Fatalf("sync: %v", err)
}
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if len(got) != 1 || got[0].ProjectPath != "acme/api" {
t.Errorf("got = %+v, want acme/api", got)
}
}
func TestService_ListTaskMRsByTask_NilWhenStoreUnset(t *testing.T) {
svc, _, _ := newServiceFixture(t, userHandler(func(string) bool { return true }))
got, err := svc.ListTaskMRsByTask(context.Background(), "task-1")
if err != nil {
t.Fatalf("err = %v", err)
}
if got != nil {
t.Errorf("got %v, want nil when store unset", got)
}
}
// Sanity: the package-level errors used by the tests are real values, not
// just docstrings — guards against accidental rename.
var _ error = ErrNoClient
var _ error = ErrInvalidToken
var _ = errors.New