Files
multica/server/internal/handler/handler_test.go
T
765321f053 MUL-6951 fix(autopilot): carry the trigger creator's authorization on scheduled runs (#7918)
* fix(autopilot): carry the trigger owner's authorization on scheduled runs

A schedule/webhook autopilot run left originator_user_id NULL, so
canInvokeAgent saw no human and fail-closed against private agents. Arming
a trigger is the same authorizing act as clicking "run now" — deferred, not
absent — so withholding the originator did not remove that authority. It
only forced every capability the automation legitimately needed to arrive
as a separately-bounded borrow path: MUL-4857 for child creation, MUL-6691
for the run's own issues, #7902 for the accountable member's issues, with
sub-issue dispatch queued up as the fourth. Each carries a hand-written
scope that no operator can see in the UI.

attribution.TriggerOwner / RuleOwner now populate UserID (the originator)
instead of only the accountable side. finalizeAttribution mirrors it, so
the originator == accountable invariant that migrations 190/197 enforce
holds without a schema change or backfill. Both autopilot execution modes
resolve through triggerOwnerAttribution, so run_only and create_issue are
covered by the same two lines.

This grants exactly the trigger owner's own rights — canInvokeAgent is
untouched, only the principal handed to it changes — and originator_source
still distinguishes a scheduled fire from a manual one in the audit trail.

The borrowed-authority machinery in handler/agent_access.go is now dead for
newly enqueued runs but is deliberately left in place: tasks enqueued by
the previous backend are still running during a rolling deploy and carry
originator NULL, and they must keep the path they were admitted under.
Deleting it is a follow-up once this is deployed.

Composio connected-app credentials also key on originator_user_id, so
scheduled runs now receive the trigger owner's connected apps. Decoupling
that from authorization was considered and explicitly deferred.

Verification (real PostgreSQL 17):
- internal/attribution, internal/service, internal/handler: PASS
- the five attribution_stamp tests that pinned "originator stays NULL" were
  updated to assert the new principal, and fail against the old constructors
- pre-existing, unrelated failures in internal/daemon/execenv (4 Hermes
  store-path tests) reproduce identically on a clean tree

Co-authored-by: multica-agent <[email protected]>

* refactor(autopilot): delete the borrowed-authority machinery

With a scheduled run carrying its trigger owner as originator, every
autopilot-shaped authority path in agent_access.go is unreachable: the
invoke gate resolves a real human from the speaking task and judges it
exactly as it judges that human acting directly.

Removed:
  - autopilotTaskAssignAuthority + autopilotAssignAuthorityFromRequest (MUL-6691)
  - issueBoundToAutopilotTask, taskRunsAutopilotInWorkspace
  - autopilotDelegationAuthority and its FromRequest / FromComment resolvers (MUL-4857)
  - effectiveInvocationAuthorityFromRequest
  - assignAuthorityScope, assignScopeKind and the four scope constructors
  - commentTriggerComputeOptions.AutopilotDelegationAuthorityUserID + effectiveInvoker()

validateAssigneePair and triggerTasksForComment lose their now-meaningless
scope / delegation-authority parameters; every caller passes the run's
originator instead. This also retires the fourth borrow scope #7902 proposed.

CreateIssue still loads the parent issue when assigning, even though the gate
no longer needs the row: it is what turns a cross-workspace or missing parent
into 400 "parent issue not found" instead of a 403. Covered by a test.

Two behaviour notes, both accepted:

  - Authority now propagates down a delegation chain, because the parent run
    finally has an originator to copy. Under the old design each hop had to
    re-borrow. This matches what a manual "run now" already did.
  - The old borrow path required task.Status == "running"; the originator path
    does not, and never did for any non-autopilot run. Task tokens are revoked
    at the terminal transition (TaskService, MUL-2600), so the primary guard
    remains; this only drops a second layer that existed solely for autopilot
    runs.

Tests: the two suites that pinned the borrow bounds (autopilot-created-issue
binding, authored-by-this-task binding) described a mechanism that no longer
exists, so they collapse into one file asserting the rule that replaced them —
rights / no rights / no human at all, across create-top-level, create-child,
update and batch. The mention-authority suite keeps its shape; its fixture now
gives the run a human, as a live run has.

Verification (real PostgreSQL 17): internal/attribution, internal/service and
internal/handler all PASS. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv reproduce identically
on a clean tree.

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): resolve one principal per dispatch and expire it with the run

Addresses Elon's review of #7918. Three findings, all confirmed against the
code before fixing.

P1-1 — admission and the run's identity could be two different people.
Automatic dispatch admitted the assignee agent as the AUTOPILOT CREATOR
(canCreatorInvokeAgent) while the task took its originator from the trigger.
With A owning both the autopilot and a private agent, and B owning the
trigger, a dispatch was admitted as A and then executed as B — rights neither
holds alone. The old suite could not see it because it called dispatchRunOnly
directly, skipping admission.

  - autopilot_trigger gains an IMMUTABLE created_by (migration 449, best-effort
    backfill from published_by). published_by keeps its audit meaning and still
    transfers on a substantive edit; it is no longer an authorization input,
    because letting an editor move it silently re-authorizes the automation as
    that editor. Per Bohan's ruling the run always acts as the trigger CREATOR.
  - ResolveAutopilotTriggerPrincipal is now the single answer to "who does this
    run act as". Admission, the task stamp and every delegated run read it, so
    the fork is unrepresentable. It also binds the trigger to its autopilot
    (GetAutopilotTriggerForAutopilot — the old lookup was WHERE id = $1, which
    would let a foreign trigger select the principal) and re-checks workspace
    membership on every dispatch, so removing a member revokes their triggers.
  - canCreatorInvokeAgent is now unreachable and deleted.
  - RuleOwner reverts to accountable-only. It is a guess at who owns a rule;
    promoting it would hand a legacy trigger somebody's rights without that
    person arming anything. Such runs carry no originator and fail closed.

P1-2 — a terminal task could still spend its human's rights.
invokeOriginatorFromRequest now refuses completed / failed / cancelled tasks.
Token revocation at the terminal transition stays the primary guard, but it is
best-effort, explicitly non-fatal on failure, with a 24h expiry behind it. The
check sits at the authorization boundary rather than the auth boundary, so a
finishing run can still post its final comment but cannot start new work.

P1-3 — the PR and code comments described Composio backwards.
BuildTaskOverlay ignores the originator entirely and builds from the AGENT
OWNER's connections and allow-list (MUL-3963). Naming the originator hands out
no third-party credentials. Comments corrected; no decoupling was ever needed.

Operational note: a trigger with neither created_by nor published_by (predating
migration 186) resolves no principal, so its autopilot stops dispatching until
the trigger is re-saved. That is the intended fail-closed behaviour.

Tests: full dispatch through admission for both fork directions; principal
fails closed on legacy / cross-autopilot / removed-member; terminal task
refused on all three statuses; the edit-transfer tests now assert the principal
STAYS with the creator while published_by moves. Verified the fork test fails
when admission is reverted to canCreatorInvokeAgent.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server all PASS. Migration 449
up/down round-trips. Pre-existing unrelated failures in cmd/multica,
internal/cli, internal/daemon and internal/daemon/execenv are unchanged.

Co-authored-by: multica-agent <[email protected]>

* fix(autopilot): bind the trigger principal to its workspace, correct stale docs

Second round of Elon's review on #7918.

1. The trigger lookup only enforced half of what its comment claimed.
GetAutopilotTriggerForAutopilot matched on (trigger id, autopilot id); the
workspace was used only for the creator's membership check afterwards. That
check proves the resolved human belongs to the workspace PASSED IN, not that
the autopilot does — a member of two workspaces satisfies it while the trigger
came from the other tenant. The query now joins autopilot and filters on
workspace_id, and the resolver passes it through. Regression test seeds two
workspaces sharing one member and asserts the foreign workspace resolves
nobody; verified it fails against the pre-fix query.

2. Several permission-boundary comments still described the pre-MUL-6951
model — that a run's authority and its accountable human transfer to whoever
last edited the trigger. That is now the opposite of the code, and these are
the comments a future reader would use to reason about the authorization
model, so they are corrected at all four sites: the trigger create site, the
SetAutopilotTriggerPublisher query, triggerOwnerAttribution, and the
dispatchRunOnly attribution block. published_by is described as what it now
is: a config-audit column with no bearing on a run.

3. The migration comment now states the backfill's imprecision plainly: for an
already-edited trigger it freezes the last recoverable EDITOR as the immutable
creator, not the historical creator, which the schema never recorded. It also
records that a NULL/NULL trigger has no recovery path by decision (Bohan:
leave them empty) — re-saving re-stamps published_by, not created_by — so its
runs stay refused with a recorded failure_reason.

Not done, per Bohan's ruling: no re-save takeover path for legacy triggers and
no migration-period alerting.

Verification (real PostgreSQL 17, pristine database): internal/attribution,
internal/service, internal/handler and cmd/server PASS; full suite shows only
the four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/... and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

* docs(autopilot): carry the creator-principal semantics into every description

Third round of Elon's review on #7918. Two must-fixes, no behaviour change.

1. The final semantics had not reached every surface that describes them.

Migration 189 wrote COMMENT ON COLUMN text for autopilot_trigger.published_by_*
saying it decides the accountable human of the runs a trigger fires. That is
now false, and because sqlc generates column comments into models.go, the
stalest description of the authorization model sat in generated code every
reader passes through. 189 is released and untouched; migration 449 overrides
both comments and documents created_by_* for the first time, and sqlc is
regenerated. 449's down restores 189's original text verbatim so a rollback
does not leave MUL-6951 wording describing a model the database no longer runs.

The same stale semantics remained in prose at six code sites — the plain and
manual dispatch entry points, the scheduled-plan path, the create_issue enqueue
comment, the autopilot-level republish path, and the webhook trigger create
site. All said a run's authority or accountable human follows the last editor,
or that automation resolves rule_owner. These sit on the authorization path, so
they are corrected rather than left to drift. published_by is now described as
what it is: config audit, with no bearing on a run.

Also corrects the backfill comment's migration reference: trigger publishers
arrived in 189, not 186.

2. The test file this PR added open-coded INSERT ... RETURNING id with matching
t.Cleanup(DELETE ...), which CLAUDE.md prohibits. Rewritten onto
testutil.Fixture: a small principalFixture embeds it and every row — members,
runtimes, private agents, autopilots, triggers, the second workspace — is built
and cleaned up by dbfx. The only remaining DELETE is the mid-test member
removal that IS the behaviour under test. Re-verified after the rewrite that
both regressions still fail against the old code: reverting admission to the
autopilot-creator check reddens the fork test, and dropping the workspace join
reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). Migration 449 up/down round-trips
and the down restores 189's comments. go vet ./internal/... and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

* docs(autopilot): state the backfill widening plainly, drop raw-SQL test seeding

Fourth round of Elon's review on #7918. Both items are leftovers from the
previous round; no behaviour change.

1. Migration 449's backfill comment carried two factual errors.

It said published_by_* arrived in migration 186; it was 189. The previous round
intended to fix this but the string replacement did not match the wrapped line
and silently did nothing, so the wrong number shipped.

It also claimed the backfill "is no wider than the authority those runs carry
today", which is false: before MUL-6951 an automatic run had no originator at
all, only narrowly-scoped borrow paths, so promoting a backfilled editor to the
run's full originator IS a widening. The product decision to accept it stands;
the comment now says so directly as a compatibility tradeoff instead of denying
the widening. A migration comment that understates what it grants is worse than
no comment.

2. The new test file still reached raw SQL transitively.

It called seedAttributionFixture, which hand-writes INSERT and cleanup for its
user / workspace / member / runtime / agent — so the file's own claim that
"every row goes through testutil.Fixture" was untrue and the repository's
fixture rule was still unmet. It now bootstraps the base identity with
Fixture.User / Workspace / Member on an unbound fixture and adopts it, which
also drops the agent and issue that helper builds and these tests never use.
The comment explains why the helper is deliberately not reused.

Re-verified after the fixture change that both regressions still fail against
the old code: reverting admission to the autopilot-creator check reddens the
fork test, and dropping the workspace join reddens the cross-workspace test.

Verification (real PostgreSQL 17, pristine database): full suite shows only the
four pre-existing unrelated failures (cmd/multica, internal/cli,
internal/daemon, internal/daemon/execenv). go vet ./internal/..., gofmt and
git diff --check clean.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-02 18:29:11 +08:00

3721 lines
132 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
var testHandler *Handler
var testPool *pgxpool.Pool
var testUserID string
var testWorkspaceID string
var testRuntimeID string
// dbfx builds the rows these tests act on and removes them again when the test
// that asked for them ends. New tests build fixtures through it rather than
// writing an INSERT and a matching DELETE by hand; see internal/testutil.
var dbfx *testutil.Fixture
const (
handlerTestEmail = "[email protected]"
handlerTestName = "Handler Test User"
handlerTestWorkspaceSlug = "handler-tests"
)
func TestMain(m *testing.M) {
ctx := context.Background()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
}
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
fmt.Printf("Skipping tests: could not connect to database: %v\n", err)
os.Exit(0)
}
if err := pool.Ping(ctx); err != nil {
fmt.Printf("Skipping tests: database not reachable: %v\n", err)
pool.Close()
os.Exit(0)
}
queries := db.New(pool)
hub := realtime.NewHub()
go hub.Run()
bus := events.New()
emailSvc := service.NewEmailService()
testHandler = New(queries, pool, hub, bus, emailSvc, nil, nil, analytics.NoopClient{}, Config{AllowSignup: true})
// httptest.NewRequest defaults RemoteAddr to 192.0.2.1, so every webhook
// test in the suite shares one IP bucket. With the production default
// (30/min) the budget runs out partway through the suite and unrelated
// downstream tests see a 429 from the IP gate instead of the response
// they're asserting. Tests that exercise rate limiting deliberately
// swap in a tight limiter with t.Cleanup; this generous default keeps
// the rest of the suite hermetic.
testHandler.WebhookRateLimiter = NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.WebhookIPRateLimiter = NewMemoryWebhookIPRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.WebhookAbsoluteIPRateLimiter = NewMemoryWebhookAbsoluteIPRateLimiter(WebhookRateLimit{Limit: 1_000_000, Window: time.Minute})
testHandler.InvitationRateLimiters = NewMemoryInvitationRateLimiters(InvitationRateLimits{
Actor: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
Workspace: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
Recipient: SlidingWindowRateLimit{Limit: 1_000_000, Window: time.Minute},
})
testPool = pool
testUserID, testWorkspaceID, err = setupHandlerTestFixture(ctx, pool)
if err != nil {
fmt.Printf("Failed to set up handler test fixture: %v\n", err)
pool.Close()
os.Exit(1)
}
dbfx = testutil.New(pool, testWorkspaceID, testUserID)
code := m.Run()
if err := cleanupHandlerTestFixture(context.Background(), pool); err != nil {
fmt.Printf("Failed to clean up handler test fixture: %v\n", err)
if code == 0 {
code = 1
}
}
pool.Close()
os.Exit(code)
}
func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, string, error) {
if err := cleanupHandlerTestFixture(ctx, pool); err != nil {
return "", "", err
}
var userID string
if err := pool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, handlerTestName, handlerTestEmail).Scan(&userID); err != nil {
return "", "", err
}
var workspaceID string
if err := pool.QueryRow(ctx, `
INSERT INTO workspace (name, slug, description, issue_prefix)
VALUES ($1, $2, $3, $4)
RETURNING id
`, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil {
return "", "", err
}
if _, err := pool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'owner')
`, workspaceID, userID); err != nil {
return "", "", err
}
var runtimeID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, $5, now())
RETURNING id
`, workspaceID, "Handler Test Runtime", "handler_test_runtime", "Handler test runtime", userID).Scan(&runtimeID); err != nil {
return "", "", err
}
testRuntimeID = runtimeID
var seededAgentID string
if err := pool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 'public_to', 1, $4)
RETURNING id
`, workspaceID, "Handler Test Agent", runtimeID, userID).Scan(&seededAgentID); err != nil {
return "", "", err
}
// MUL-3963: the seeded workspace-visible agent is invocable by workspace
// members and A2A triggers, so seed its workspace invocation target.
if _, err := pool.Exec(ctx, `
INSERT INTO agent_invocation_target (agent_id, target_type, target_id)
VALUES ($1, 'workspace', $2)
ON CONFLICT (agent_id, target_type, target_id) DO NOTHING
`, seededAgentID, workspaceID); err != nil {
return "", "", err
}
return userID, workspaceID, nil
}
func cleanupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) error {
var hasClientUsageTable bool
if err := pool.QueryRow(ctx, `SELECT to_regclass('client_usage_daily') IS NOT NULL`).Scan(&hasClientUsageTable); err != nil {
return err
}
if hasClientUsageTable {
if _, err := pool.Exec(ctx, `DELETE FROM client_usage_daily WHERE user_id IN (SELECT id FROM "user" WHERE email = $1)`, handlerTestEmail); err != nil {
return err
}
}
if _, err := pool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, handlerTestWorkspaceSlug); err != nil {
return err
}
if _, err := pool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, handlerTestEmail); err != nil {
return err
}
return nil
}
func newRequest(method, path string, body any) *http.Request {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
req.Header.Set("X-Workspace-ID", testWorkspaceID)
return req
}
func withURLParam(req *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func setWorkspaceIssuePrefixForTest(t *testing.T, prefix string) {
t.Helper()
var previous string
dbfx.QueryRow(t, `SELECT issue_prefix FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&previous)
dbfx.Exec(t, `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, prefix, testWorkspaceID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, previous, testWorkspaceID)
})
}
func handlerTestRuntimeID(t *testing.T) string {
t.Helper()
var runtimeID string
dbfx.QueryRow(t,
`SELECT id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&runtimeID)
return runtimeID
}
func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string {
t.Helper()
agentID := dbfx.Agent(t, name, handlerTestRuntimeID(t), testutil.Cols{
"visibility": "workspace",
"permission_mode": "public_to",
"instructions": "",
"custom_env": testutil.Raw("'{}'::jsonb"),
"custom_args": testutil.Raw("'[]'::jsonb"),
"mcp_config": mcpConfig,
})
// Generic test agents are workspace-invocable (MUL-3963): seed the
// matching workspace invocation target so canInvokeAgent admits workspace
// members and A2A triggers, mirroring the pre-permission-model behavior
// where a workspace-visible agent could be triggered by anyone in the
// workspace. Dedicated private-agent tests use privateAgentTestFixture.
dbfx.Exec(t, `
INSERT INTO agent_invocation_target (agent_id, target_type, target_id)
VALUES ($1, 'workspace', $2)
ON CONFLICT (agent_id, target_type, target_id) DO NOTHING
`, agentID, testWorkspaceID)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID
}
// createHandlerTestTaskForAgent seeds a running agent_task_queue row for the
// given agent (with no associated issue) and returns the task UUID. Used by
// tests that need to set X-Task-ID alongside X-Agent-ID — resolveActor now
// requires the pair to be present and consistent before granting "agent"
// actor identity.
func createHandlerTestTaskForAgent(t *testing.T, agentID string) string {
return createHandlerTestTaskForAgentOnIssue(t, agentID, "")
}
// createHandlerTestTaskForAgentOnIssue seeds a running agent_task_queue row
// for the given agent, optionally bound to an issue (pass "" to leave
// issue_id NULL). The bound-issue form is needed by the self-loop guard
// test, which compares the calling task's issue_id against the promoted
// issue — only a same-issue match counts as a true self-loop.
//
// Status is 'running' because X-Task-ID is something a currently-executing
// task sends. Using 'running' also keeps the seed outside the
// idx_one_pending_task_per_issue_agent unique index (queued/dispatched only)
// and outside callers' `status='queued'` count assertions, so tests can
// assert that the handler did or did not enqueue a NEW task without
// double-counting the seed.
func createHandlerTestTaskForAgentOnIssue(t *testing.T, agentID, issueID string) string {
t.Helper()
var issueArg any
if issueID == "" {
issueArg = nil
} else {
issueArg = issueID
}
taskID := dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": handlerTestRuntimeID(t),
"status": "running",
"issue_id": issueArg,
"started_at": testutil.Raw("now()"),
})
return taskID
}
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
t.Helper()
var mcpConfig []byte
dbfx.QueryRow(t, `SELECT mcp_config FROM agent WHERE id = $1`, agentID).Scan(&mcpConfig)
return mcpConfig
}
func assertJSONEqual(t *testing.T, got []byte, want string) {
t.Helper()
var gotValue any
if err := json.Unmarshal(got, &gotValue); err != nil {
t.Fatalf("failed to unmarshal got JSON %q: %v", string(got), err)
}
var wantValue any
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
t.Fatalf("failed to unmarshal want JSON %q: %v", want, err)
}
gotJSON, err := json.Marshal(gotValue)
if err != nil {
t.Fatalf("failed to marshal normalized got JSON: %v", err)
}
wantJSON, err := json.Marshal(wantValue)
if err != nil {
t.Fatalf("failed to marshal normalized want JSON: %v", err)
}
if string(gotJSON) != string(wantJSON) {
t.Fatalf("expected JSON %s, got %s", string(wantJSON), string(gotJSON))
}
}
func TestIssueCRUD(t *testing.T) {
// Create
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Test issue from Go test",
"status": "todo",
"priority": "medium",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Title != "Test issue from Go test" {
t.Fatalf("CreateIssue: expected title 'Test issue from Go test', got '%s'", created.Title)
}
if created.Status != "todo" {
t.Fatalf("CreateIssue: expected status 'todo', got '%s'", created.Status)
}
issueID := created.ID
// Get
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var fetched IssueResponse
json.NewDecoder(w.Body).Decode(&fetched)
if fetched.ID != issueID {
t.Fatalf("GetIssue: expected id '%s', got '%s'", issueID, fetched.ID)
}
// Update - partial (only status)
w = httptest.NewRecorder()
status := "in_progress"
req = newRequest("PUT", "/api/issues/"+issueID, map[string]any{
"status": status,
})
req = withURLParam(req, "id", issueID)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "in_progress" {
t.Fatalf("UpdateIssue: expected status 'in_progress', got '%s'", updated.Status)
}
if updated.Title != "Test issue from Go test" {
t.Fatalf("UpdateIssue: title should be preserved, got '%s'", updated.Title)
}
if updated.Priority != "medium" {
t.Fatalf("UpdateIssue: priority should be preserved, got '%s'", updated.Priority)
}
// List
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
testHandler.ListIssues(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ListIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var listResp map[string]any
json.NewDecoder(w.Body).Decode(&listResp)
issues := listResp["issues"].([]any)
if len(issues) == 0 {
t.Fatal("ListIssues: expected at least 1 issue")
}
// Delete
w = httptest.NewRecorder()
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteIssue: expected 204, got %d: %s", w.Code, w.Body.String())
}
// Verify deleted
w = httptest.NewRecorder()
req = newRequest("GET", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.GetIssue(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetIssue after delete: expected 404, got %d", w.Code)
}
}
// TestDeleteIssueByIdentifier guards against #1661 — DELETE /api/issues/{id}
// must actually delete the row when the path segment is a human-readable
// identifier ("HAN-42") rather than a UUID. Before the PR #1680 + MUL-1410
// refactor, parseUUID(rawString) silently produced a zero UUID, the SQL
// DELETE matched nothing, and the handler still returned 204.
//
// Also asserts the issue:deleted WS event payload carries the resolved UUID,
// not the raw identifier — frontend caches key by UUID and would otherwise
// leave stale entries on other clients after an identifier-path delete.
func TestDeleteIssueByIdentifier(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue to delete by identifier",
"status": "todo",
"priority": "medium",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Identifier == "" {
t.Fatalf("CreateIssue: expected identifier to be populated, got empty")
}
// Capture the issue:deleted event payload via the bus.
gotPayload := make(chan map[string]any, 1)
testHandler.Bus.Subscribe(protocol.EventIssueDeleted, func(e events.Event) {
if payload, ok := e.Payload.(map[string]any); ok {
select {
case gotPayload <- payload:
default:
}
}
})
// Delete using the human-readable identifier (e.g. "HAN-1") rather than the UUID.
req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil)
req = withURLParam(req, "id", created.Identifier)
w = testutil.Call(t, testHandler.DeleteIssue, req).Want(http.StatusNoContent)
// Verify the row is actually gone — the silent-data-loss bug would have
// returned 204 here too, but the row would still exist.
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID,
).Scan(&count)
if count != 0 {
t.Fatalf("DeleteIssue by identifier returned 204 but row still exists (count=%d) — silent-data-loss regression", count)
}
// Event payload must carry the resolved UUID, not the identifier string.
select {
case payload := <-gotPayload:
issueID, _ := payload["issue_id"].(string)
if issueID != created.ID {
t.Fatalf("issue:deleted event payload issue_id = %q; want resolved UUID %q (must not leak identifier %q)", issueID, created.ID, created.Identifier)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive issue:deleted event within timeout")
}
}
// TestGetIssueByIdentifierEnforcesPrefix covers the contract behind the
// human-readable issue URL `/{ws}/issues/{key}`: the prefix is part of the key,
// not decoration. Identifier resolution used to compare the number only, so
// every prefix with the right number opened the same issue — which means no
// identifier URL could be treated as canonical, and a mistyped or stale prefix
// silently opened someone else's link target.
func TestGetIssueByIdentifierEnforcesPrefix(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue addressed by identifier",
"status": "todo",
"priority": "medium",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
idx := strings.LastIndex(created.Identifier, "-")
if idx <= 0 {
t.Fatalf("CreateIssue: unexpected identifier %q", created.Identifier)
}
prefix, number := created.Identifier[:idx], created.Identifier[idx+1:]
// The workspace's own prefix resolves, in either case — a hand-typed
// lowercase key from a chat message must open the same issue.
for _, id := range []string{created.Identifier, strings.ToLower(created.Identifier)} {
req = newRequest("GET", "/api/issues/"+id, nil)
req = withURLParam(req, "id", id)
w = testutil.Call(t, testHandler.GetIssue, req)
if w.Code != http.StatusOK {
t.Fatalf("GetIssue(%q): expected 200, got %d: %s", id, w.Code, w.Body.String())
}
var got IssueResponse
json.NewDecoder(w.Body).Decode(&got)
if got.ID != created.ID {
t.Fatalf("GetIssue(%q): resolved to %s, want %s", id, got.ID, created.ID)
}
}
// A foreign prefix carrying the right number must not resolve.
foreign := prefix + "X-" + number
req = newRequest("GET", "/api/issues/"+foreign, nil)
req = withURLParam(req, "id", foreign)
w = testutil.Call(t, testHandler.GetIssue, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetIssue(%q): expected 404 for a foreign prefix, got %d: %s", foreign, w.Code, w.Body.String())
}
}
// TestDeleteIssueRejectsInvalidUUID verifies that a path segment that is
// neither a valid UUID nor a valid identifier returns 404 (not 204) — the
// handler must never silently succeed on malformed input.
func TestDeleteIssueRejectsInvalidUUID(t *testing.T) {
req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil)
req = withURLParam(req, "id", "not-a-uuid-or-identifier")
w := testutil.Call(t, testHandler.DeleteIssue, req)
if w.Code == http.StatusNoContent {
t.Fatalf("DeleteIssue with invalid id: must not return 204; got %d", w.Code)
}
if w.Code != http.StatusNotFound {
t.Fatalf("DeleteIssue with invalid id: expected 404, got %d: %s", w.Code, w.Body.String())
}
}
// TestCreateIssueDefaultStatusIsTodo verifies that issues created without an
// explicit status default to "todo" so the daemon picks them up immediately.
// Before this fix the default was "backlog", which daemons ignore.
func TestCreateIssueDefaultStatusIsTodo(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue with no explicit status",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Status != "todo" {
t.Fatalf("CreateIssue: expected default status 'todo', got '%s'", created.Status)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueExplicitBacklogPreserved verifies that explicitly requesting
// "backlog" status is still respected — only the implicit default changed.
func TestCreateIssueExplicitBacklogPreserved(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Explicit backlog issue",
"status": "backlog",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
if created.Status != "backlog" {
t.Fatalf("CreateIssue: expected explicit 'backlog' to be preserved, got '%s'", created.Status)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueRejectsCrossWorkspaceParent guards the workspace
// boundary check that lives in service.IssueService.Create. A request
// that pins parent_issue_id to an issue in a foreign workspace must be
// rejected before the row is created — this is the structural reason
// IssueService owns the parent lookup (not the HTTP handler). The test
// inserts a foreign workspace + issue directly via SQL, then drives the
// request through the regular handler entry point.
func TestCreateIssueRejectsCrossWorkspaceParent(t *testing.T) {
otherWorkspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Cross-workspace parent test",
"slug": "xwp-parent-test",
"description": "Foreign workspace",
"issue_prefix": "XWP",
})
foreignParentID := dbfx.Issue(t, "Foreign parent", testutil.Cols{
"workspace_id": otherWorkspaceID,
"number": 1,
})
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Should be rejected",
"parent_issue_id": foreignParentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
if !strings.Contains(w.Body.String(), "parent issue not found in this workspace") {
t.Fatalf("CreateIssue with foreign parent: expected boundary error message, got %s", w.Body.String())
}
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, "Should be rejected",
).Scan(&count)
if count != 0 {
t.Fatalf("rejected create still wrote a row (count=%d) — service-layer boundary check failed", count)
}
}
// TestCreateIssueRejectsCrossWorkspaceProject mirrors the parent test for
// the project workspace boundary. Same reasoning: future create entries
// (Lark /issue, MCP, API keys) must inherit this guard from the service
// without re-implementing it.
func TestCreateIssueRejectsCrossWorkspaceProject(t *testing.T) {
otherWorkspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Cross-workspace project test",
"slug": "xwp-project-test",
"description": "Foreign workspace",
"issue_prefix": "XWP",
})
foreignProjectID := dbfx.Project(t, "Foreign project", testutil.Cols{
"workspace_id": otherWorkspaceID,
})
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Should be rejected",
"project_id": foreignProjectID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
if !strings.Contains(w.Body.String(), "project not found in this workspace") {
t.Fatalf("CreateIssue with foreign project: expected boundary error message, got %s", w.Body.String())
}
var count int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, "Should be rejected",
).Scan(&count)
if count != 0 {
t.Fatalf("rejected create still wrote a row (count=%d) — service-layer boundary check failed", count)
}
}
func TestCreateSubIssueInheritsParentProject(t *testing.T) {
var projectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
if projectID != "" {
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Sub-issue inheritance project",
})
w := testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var project ProjectResponse
json.NewDecoder(w.Body).Decode(&project)
projectID = project.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != projectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", projectID, parent.ProjectID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child without explicit project",
"parent_issue_id": parentID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != projectID {
t.Fatalf("CreateIssue child: expected inherited project_id %q, got %v", projectID, child.ProjectID)
}
}
func TestCreateSubIssueUsesExplicitProjectOverParentProject(t *testing.T) {
var parentProjectID, childProjectID, parentID, childID string
defer func() {
for _, issueID := range []string{childID, parentID} {
if issueID == "" {
continue
}
req := newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
testHandler.DeleteIssue(httptest.NewRecorder(), req)
}
for _, projectID := range []string{childProjectID, parentProjectID} {
if projectID == "" {
continue
}
req := newRequest("DELETE", "/api/projects/"+projectID, nil)
req = withURLParam(req, "id", projectID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
}
}()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent project",
})
w := testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var parentProject ProjectResponse
json.NewDecoder(w.Body).Decode(&parentProject)
parentProjectID = parentProject.ID
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child explicit project",
})
w = testutil.Call(t, testHandler.CreateProject, req).Want(http.StatusCreated)
var childProject ProjectResponse
json.NewDecoder(w.Body).Decode(&childProject)
childProjectID = childProject.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Parent with project",
"project_id": parentProjectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
json.NewDecoder(w.Body).Decode(&parent)
parentID = parent.ID
if parent.ProjectID == nil || *parent.ProjectID != parentProjectID {
t.Fatalf("CreateIssue parent: expected project_id %q, got %v", parentProjectID, parent.ProjectID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Child with explicit project",
"parent_issue_id": parentID,
"project_id": childProjectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var child IssueResponse
json.NewDecoder(w.Body).Decode(&child)
childID = child.ID
if child.ParentIssueID == nil || *child.ParentIssueID != parentID {
t.Fatalf("CreateIssue child: expected parent_issue_id %q, got %v", parentID, child.ParentIssueID)
}
if child.ProjectID == nil || *child.ProjectID != childProjectID {
t.Fatalf("CreateIssue child: expected explicit project_id %q, got %v", childProjectID, child.ProjectID)
}
}
func TestCreateIssueRejectsActiveDuplicate(t *testing.T) {
ctx := context.Background()
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
var projectID, parentID, issueID, duplicateID string
defer func() {
for _, id := range []string{duplicateID, issueID, parentID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, "Duplicate guard project "+suffix).Scan(&projectID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Duplicate guard parent " + suffix,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var parent IssueResponse
w.JSON(&parent)
parentID = parent.ID
title := "SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs " + suffix
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "in_progress",
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var original IssueResponse
w.JSON(&original)
issueID = original.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": " sh-pm-synth-01 synthesize recommendation-to-shortlist planning outputs " + suffix + " ",
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusConflict)
var conflict struct {
Code string `json:"code"`
Error string `json:"error"`
Issue IssueResponse `json:"issue"`
}
w.JSON(&conflict)
if conflict.Code != "active_duplicate_issue" {
t.Fatalf("code = %q, want active_duplicate_issue", conflict.Code)
}
if conflict.Issue.ID != issueID || conflict.Issue.Status != "in_progress" {
t.Fatalf("conflict issue = %#v, want original %s in_progress", conflict.Issue, issueID)
}
if !strings.Contains(conflict.Error, original.Identifier+" "+title) || !strings.Contains(conflict.Error, "allow_duplicate=true") || !strings.Contains(conflict.Error, "--allow-duplicate") {
t.Fatalf("unexpected duplicate message: %q", conflict.Error)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"parent_issue_id": parentID,
"project_id": projectID,
"allow_duplicate": true,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var duplicate IssueResponse
w.JSON(&duplicate)
duplicateID = duplicate.ID
if duplicateID == issueID {
t.Fatalf("allow duplicate returned original issue id %s", duplicateID)
}
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"parent_issue_id": parentID,
"project_id": projectID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusConflict)
w.JSON(&conflict)
if conflict.Issue.ID != issueID {
t.Fatalf("conflict issue = %s, want oldest active issue %s", conflict.Issue.ID, issueID)
}
}
func TestCreateIssueAllowsDuplicateAfterCancelled(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Cancelled duplicate guard %d", time.Now().UnixNano())
var firstID, secondID string
defer func() {
for _, id := range []string{secondID, firstID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
}()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "cancelled",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var first IssueResponse
w.JSON(&first)
firstID = first.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var second IssueResponse
w.JSON(&second)
secondID = second.ID
if secondID == firstID {
t.Fatalf("new issue reused cancelled issue id %s", secondID)
}
}
func TestCreateIssueAllowsDuplicateAfterDone(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Done duplicate guard %d", time.Now().UnixNano())
var firstID, secondID string
defer func() {
for _, id := range []string{secondID, firstID} {
if id != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id)
}
}
}()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "done",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var first IssueResponse
w.JSON(&first)
firstID = first.ID
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var second IssueResponse
w.JSON(&second)
secondID = second.ID
if secondID == firstID {
t.Fatalf("new issue reused done issue id %s", secondID)
}
}
func TestTriggerAutopilotAllowsActiveDuplicateIssue(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot duplicate issue %d", time.Now().UnixNano())
var autopilotID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
testPool.Exec(ctx, `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title)
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var existing IssueResponse
w.JSON(&existing)
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Duplicate title autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w = testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
req = newRequest("POST", "/api/autopilots/"+autopilotID+"/trigger?workspace_id="+testWorkspaceID, nil)
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.TriggerAutopilot, req).Want(http.StatusOK)
var run AutopilotRunResponse
w.JSON(&run)
if run.Status != "issue_created" {
t.Fatalf("run status = %q, want issue_created", run.Status)
}
if run.IssueID == nil {
t.Fatal("run issue_id is nil, want newly created issue")
}
if *run.IssueID == existing.ID {
t.Fatalf("run reused existing issue %s, want a new issue", existing.ID)
}
if run.FailureReason != nil {
t.Fatalf("run failure_reason = %q, want nil", *run.FailureReason)
}
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count)
if count != 2 {
t.Fatalf("autopilot should create a new same-title issue, got %d matching issues", count)
}
}
func TestScheduledAutopilotAllowsActiveDuplicateIssue(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Scheduled autopilot duplicate issue %d", time.Now().UnixNano())
var autopilotID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
testPool.Exec(ctx, `DELETE FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title)
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var existing IssueResponse
w.JSON(&existing)
req = newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Scheduled duplicate title autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w = testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
// A scheduled dispatch always fires through a trigger, and since MUL-6951 that
// trigger is what names the human the run acts as. Seed one rather than passing
// a zero id, which is a shape the scheduler cannot produce.
scheduleTriggerID := dbfx.Insert(t, "autopilot_trigger", testutil.Cols{
"autopilot_id": autopilotID,
"kind": "schedule",
"cron_expression": "0 * * * *",
"created_by_type": "member",
"created_by_id": testUserID,
})
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, parseUUID(scheduleTriggerID), "schedule", nil)
if err != nil {
t.Fatalf("DispatchAutopilot schedule duplicate: %v", err)
}
if run == nil || run.Status != "issue_created" {
t.Fatalf("dispatch result = %+v, want status issue_created", run)
}
newIssueID := uuidToString(run.IssueID)
if newIssueID == "" {
t.Fatal("run issue_id is empty, want newly created issue")
}
if newIssueID == existing.ID {
t.Fatalf("run reused existing issue %s, want a new issue", existing.ID)
}
if run.FailureReason.Valid {
t.Fatalf("run failure_reason = %q, want empty", run.FailureReason.String)
}
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count)
if count != 2 {
t.Fatalf("autopilot should create a new same-title issue, got %d matching issues", count)
}
}
// TestAutopilotCreatedIssueCreatorIsAssigneeAgent locks in that an issue spawned
// by an autopilot reports the assignee agent — not the human who configured the
// autopilot — as its creator. The matching issue:created event must carry the
// same actor identity so downstream activity / notification listeners stay in
// sync with the issue row.
func TestAutopilotCreatedIssueCreatorIsAssigneeAgent(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot creator attribution %d", time.Now().UnixNano())
var autopilotID, issueID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
}()
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Creator attribution autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
if autopilot.CreatedByType != "member" || autopilot.CreatedByID != testUserID {
t.Fatalf("autopilot created_by = %s/%s, want member/%s", autopilot.CreatedByType, autopilot.CreatedByID, testUserID)
}
gotEvent := make(chan events.Event, 1)
testHandler.Bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
select {
case gotEvent <- e:
default:
}
})
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, _, err := testHandler.AutopilotService.DispatchAutopilotManual(ctx, ap, pgtype.UUID{}, nil, parseUUID(testUserID))
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || run.Status != "issue_created" {
t.Fatalf("dispatch result = %+v, want status issue_created", run)
}
var creatorType, creatorID string
dbfx.QueryRow(t, `
SELECT id, creator_type, creator_id
FROM issue
WHERE workspace_id = $1 AND title = $2
ORDER BY created_at DESC
LIMIT 1
`, testWorkspaceID, title).Scan(&issueID, &creatorType, &creatorID)
if creatorType != "agent" {
t.Fatalf("issue creator_type = %q, want agent", creatorType)
}
if creatorID != agentID {
t.Fatalf("issue creator_id = %q, want assignee agent %q", creatorID, agentID)
}
select {
case ev := <-gotEvent:
if ev.ActorType != "agent" {
t.Fatalf("issue:created ActorType = %q, want agent", ev.ActorType)
}
if ev.ActorID != agentID {
t.Fatalf("issue:created ActorID = %q, want %q", ev.ActorID, agentID)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive issue:created event")
}
}
func TestAutopilotCreateIssueAssociatesConfiguredProject(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot project issue %d", time.Now().UnixNano())
var autopilotID, issueID, projectID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot project target").Scan(&projectID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project-linked autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
"project_id": projectID,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var autopilot AutopilotResponse
w.JSON(&autopilot)
autopilotID = autopilot.ID
if autopilot.ProjectID == nil || *autopilot.ProjectID != projectID {
t.Fatalf("autopilot project_id = %v, want %q", autopilot.ProjectID, projectID)
}
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
run, _, err := testHandler.AutopilotService.DispatchAutopilotManual(ctx, ap, pgtype.UUID{}, nil, parseUUID(testUserID))
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || !run.IssueID.Valid {
t.Fatalf("dispatch run = %+v, want linked issue", run)
}
issueID = uuidToString(run.IssueID)
var issueProjectID *string
dbfx.QueryRow(t, `
SELECT project_id::text
FROM issue
WHERE id = $1
`, issueID).Scan(&issueProjectID)
if issueProjectID == nil || *issueProjectID != projectID {
t.Fatalf("created issue project_id = %v, want %q", issueProjectID, projectID)
}
}
func TestAutopilotDispatchUsesCurrentProjectBinding(t *testing.T) {
ctx := context.Background()
title := fmt.Sprintf("Autopilot stale project issue %d", time.Now().UnixNano())
var autopilotID, issueID, projectAID, projectBID string
defer func() {
if issueID != "" {
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
}
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectAID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectAID)
}
if projectBID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectBID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot stale project A").Scan(&projectAID)
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot stale project B").Scan(&projectBID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Stale-project autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
"issue_title_template": title,
"project_id": projectAID,
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var created AutopilotResponse
w.JSON(&created)
autopilotID = created.ID
queries := db.New(testPool)
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
if err != nil {
t.Fatalf("GetAutopilot: %v", err)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": projectBID,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
run, _, err := testHandler.AutopilotService.DispatchAutopilotManual(ctx, ap, pgtype.UUID{}, nil, parseUUID(testUserID))
if err != nil {
t.Fatalf("DispatchAutopilot: %v", err)
}
if run == nil || !run.IssueID.Valid {
t.Fatalf("dispatch run = %+v, want linked issue", run)
}
issueID = uuidToString(run.IssueID)
var issueProjectID *string
dbfx.QueryRow(t, `
SELECT project_id::text
FROM issue
WHERE id = $1
`, issueID).Scan(&issueProjectID)
if issueProjectID == nil || *issueProjectID != projectBID {
t.Fatalf("created issue project_id = %v, want refreshed %q", issueProjectID, projectBID)
}
}
func TestUpdateAutopilotCanSetAndClearProject(t *testing.T) {
ctx := context.Background()
var autopilotID, projectID string
defer func() {
if autopilotID != "" {
testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
}
if projectID != "" {
testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID)
}
}()
dbfx.QueryRow(t, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id::text
`, testWorkspaceID, "Autopilot update project target").Scan(&projectID)
var agentID string
dbfx.QueryRow(t, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID)
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
"title": "Project update autopilot",
"assignee_id": agentID,
"execution_mode": "create_issue",
})
w := testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusCreated)
var created AutopilotResponse
w.JSON(&created)
autopilotID = created.ID
if created.ProjectID != nil {
t.Fatalf("new autopilot project_id = %v, want nil", created.ProjectID)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": projectID,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
var updated AutopilotResponse
w.JSON(&updated)
if updated.ProjectID == nil || *updated.ProjectID != projectID {
t.Fatalf("updated project_id = %v, want %q", updated.ProjectID, projectID)
}
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
"project_id": nil,
})
req = withURLParam(req, "id", autopilotID)
w = testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusOK)
var cleared AutopilotResponse
w.JSON(&cleared)
if cleared.ProjectID != nil {
t.Fatalf("cleared project_id = %v, want nil", cleared.ProjectID)
}
}
// TestCreateIssueRejectsNonexistentMemberAssignee covers the bug where any
// well-formed UUID was accepted as assignee_id without checking workspace
// membership.
func TestCreateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Ghost member assignee",
"assignee_type": "member",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsNonexistentAgentAssignee verifies the same check on
// the agent branch — previously rejected with 403 "agent not found"; we want a
// consistent 400 from the new validator.
func TestCreateIssueRejectsNonexistentAgentAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Ghost agent assignee",
"assignee_type": "agent",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsAssigneeTypeWithoutID rejects requests where only one
// of the two fields was supplied — historically this would create an issue
// with an inconsistent state.
func TestCreateIssueRejectsAssigneeTypeWithoutID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Lone assignee_type",
"assignee_type": "member",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsAssigneeIDWithoutType is the symmetric case.
func TestCreateIssueRejectsAssigneeIDWithoutType(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Lone assignee_id",
"assignee_id": testUserID,
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueRejectsUnknownAssigneeType guards against typos like
// "members" or "user" that previously sneaked through.
func TestCreateIssueRejectsUnknownAssigneeType(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Bogus assignee_type",
"assignee_type": "user",
"assignee_id": testUserID,
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
// TestCreateIssueAcceptsValidMemberAssignee is the positive control — the
// validator must not block legitimate workspace members.
func TestCreateIssueAcceptsValidMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Valid member assignee",
"assignee_type": "member",
"assignee_id": testUserID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestCreateIssueRejectsMalformedAssigneeID covers the case where parseUUID
// silently produces an invalid pgtype.UUID and the validator would otherwise
// treat (no type + unparseable id) as "no assignee" and accept the request.
func TestCreateIssueRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Malformed assignee_id only",
"assignee_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
}
func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) {
var before int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Malformed attachment issue",
"attachment_ids": []string{"not-a-uuid"},
})
testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusBadRequest)
var after int
dbfx.QueryRow(t, `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after)
if after != before {
t.Fatalf("CreateIssue: malformed attachment_ids should not create issue, count before=%d after=%d", before, after)
}
}
// TestUpdateIssueRejectsMalformedAssigneeID is the equivalent for the update
// path, where the same parseUUID-shaped gap existed on a previously-unassigned
// issue.
func TestUpdateIssueRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Update malformed assignee target",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_id": "not-a-uuid",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusBadRequest)
}
// TestUpdateIssueRejectsNonexistentMemberAssignee verifies the same gap is
// closed on the update path — UpdateIssue previously only validated agents.
func TestUpdateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Update assignee target",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_type": "member",
"assignee_id": "00000000-0000-0000-0000-000000000000",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusBadRequest)
}
// TestUpdateIssueAllowsExplicitUnassign verifies that sending null for both
// fields still works after the new validator landed — clearing the assignee
// must not be misclassified as a mismatched pair.
func TestUpdateIssueAllowsExplicitUnassign(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Issue to unassign",
"assignee_type": "member",
"assignee_id": testUserID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
defer func() {
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"assignee_type": nil,
"assignee_id": nil,
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var updated IssueResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.AssigneeType != nil || updated.AssigneeID != nil {
t.Fatalf("UpdateIssue: expected assignee cleared, got type=%v id=%v", updated.AssigneeType, updated.AssigneeID)
}
}
func TestCommentCRUD(t *testing.T) {
// Create an issue first
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment test issue",
})
w := testutil.Call(t, testHandler.CreateIssue, req)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
// Create comment
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "Test comment from Go test",
})
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusCreated)
// List comments
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.ListComments, req).Want(http.StatusOK)
var comments []CommentResponse
json.NewDecoder(w.Body).Decode(&comments)
if len(comments) != 1 {
t.Fatalf("ListComments: expected 1 comment, got %d", len(comments))
}
if comments[0].Content != "Test comment from Go test" {
t.Fatalf("ListComments: expected content 'Test comment from Go test', got '%s'", comments[0].Content)
}
// Cleanup
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
req = withURLParam(req, "id", issueID)
w = testutil.Call(t, testHandler.DeleteIssue, req)
}
func TestCommentWritePathsPreserveIssueIdentifiers(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("requires DB")
}
setWorkspaceIssuePrefixForTest(t, "MUL")
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "preserve bare issue identifiers",
"number": 3310,
})
explicitMention := fmt.Sprintf("[MUL-3310](mention://issue/%s)", issueID)
createCases := []string{
"MUL-3310",
"issue/MUL-3310",
"feature/MUL-3310",
explicitMention,
}
var firstCommentID string
for _, content := range createCases {
req := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": content,
})
req = withURLParam(req, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment(%q): expected 201, got %d: %s", content, w.Code, w.Body.String())
}
var created CommentResponse
w.JSON(&created)
if created.Content != content {
t.Fatalf("CreateComment(%q) stored %q", content, created.Content)
}
if firstCommentID == "" {
firstCommentID = created.ID
}
}
updatedContent := "updated MUL-3310 issue/MUL-3310 feature/MUL-3310 " + explicitMention
req := newRequest("PUT", "/api/comments/"+firstCommentID, map[string]any{
"content": updatedContent,
})
req = withURLParam(req, "commentId", firstCommentID)
w := testutil.Call(t, testHandler.UpdateComment, req).Want(http.StatusOK)
var updated CommentResponse
w.JSON(&updated)
if updated.Content != updatedContent {
t.Fatalf("UpdateComment stored %q, want %q", updated.Content, updatedContent)
}
}
func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Comment malformed parent issue",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
req = newRequest("POST", "/api/issues/"+issue.ID+"/comments", map[string]any{
"content": "bad parent",
"parent_id": "not-a-uuid",
})
req = withURLParam(req, "id", issue.ID)
w = testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusBadRequest)
req = newRequest("DELETE", "/api/issues/"+issue.ID, nil)
req = withURLParam(req, "id", issue.ID)
w = testutil.Call(t, testHandler.DeleteIssue, req)
}
func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) {
req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil)
req = withURLParam(req, "sessionId", "not-a-uuid")
testutil.Call(t, testHandler.GetChatSession, req).Want(http.StatusBadRequest)
}
func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) {
req := newRequest("POST", "/api/autopilots", map[string]any{
"title": "Malformed assignee autopilot",
"assignee_id": "not-a-uuid",
"execution_mode": "run_only",
})
testutil.Call(t, testHandler.CreateAutopilot, req).Want(http.StatusBadRequest)
}
func TestUpdateAutopilotRejectsMalformedID(t *testing.T) {
req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{
"title": "Malformed autopilot id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateAutopilot, req).Want(http.StatusBadRequest)
}
func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) {
req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{
"name": "Malformed agent id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusBadRequest)
}
func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) {
req := newRequest("POST", "/api/agents", map[string]any{
"name": "Malformed runtime agent",
"runtime_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreateAgent, req).Want(http.StatusBadRequest)
}
func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil)
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"runtime_id": "not-a-uuid",
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusBadRequest)
}
func TestCreatePinRejectsMalformedItemID(t *testing.T) {
req := newRequest("POST", "/api/pins", map[string]any{
"item_type": "issue",
"item_id": "not-a-uuid",
})
testutil.Call(t, testHandler.CreatePin, req).Want(http.StatusBadRequest)
}
func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) {
req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{
"name": "Malformed workspace id",
})
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.UpdateWorkspace, req).Want(http.StatusBadRequest)
}
func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) {
req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{
"role": "member",
})
req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid")
testutil.Call(t, testHandler.UpdateMember, req).Want(http.StatusBadRequest)
}
func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) {
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil)
req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid")
testutil.Call(t, testHandler.RevokeInvitation, req).Want(http.StatusBadRequest)
}
func TestGetMyInvitationRejectsMalformedID(t *testing.T) {
req := newRequest("GET", "/api/invitations/not-a-uuid", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.GetMyInvitation, req).Want(http.StatusBadRequest)
}
func TestAddReactionRejectsMalformedCommentID(t *testing.T) {
req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{
"emoji": "thumbs_up",
})
req = withURLParam(req, "commentId", "not-a-uuid")
testutil.Call(t, testHandler.AddReaction, req).Want(http.StatusBadRequest)
}
func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) {
req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{
"content": "updated",
})
req = withURLParam(req, "commentId", "not-a-uuid")
testutil.Call(t, testHandler.UpdateComment, req).Want(http.StatusBadRequest)
}
func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) {
req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.MarkInboxRead, req).Want(http.StatusBadRequest)
}
func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) {
req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil)
req = withURLParam(req, "id", "not-a-uuid")
testutil.Call(t, testHandler.RevokePersonalAccessToken, req).Want(http.StatusBadRequest)
}
func TestRequestBodyUUIDFieldsRejectMalformed(t *testing.T) {
tests := []struct {
name string
req *http.Request
handle func(http.ResponseWriter, *http.Request)
}{
{
name: "daemon register workspace_id",
req: newRequest("POST", "/api/daemon/register", map[string]any{
"workspace_id": "not-a-uuid",
"daemon_id": "daemon-malformed-workspace",
"runtimes": []map[string]any{
{"name": "codex", "type": "codex", "status": "online"},
},
}),
handle: testHandler.DaemonRegister,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
tt.handle(w, tt.req)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: expected 400 for malformed body UUID, got %d: %s", tt.name, w.Code, w.Body.String())
}
})
}
}
func TestDaemonDeregisterRejectsMalformedRuntimeID(t *testing.T) {
req := newRequest("POST", "/api/daemon/deregister", map[string]any{
"runtime_ids": []string{"not-a-uuid"},
})
testutil.Call(t, testHandler.DaemonDeregister, req).Want(http.StatusBadRequest)
}
func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) {
req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil)
req = withURLParam(req, "issueId", "not-a-uuid")
testutil.Call(t, testHandler.GetIssueGCCheck, req).Want(http.StatusBadRequest)
}
func TestBatchIssueGCCheckRejectsInvalidRequests(t *testing.T) {
h := &Handler{}
workspaceID := "00000000-0000-0000-0000-000000000001"
tooMany := make([]string, maxIssueGCBatchSize+1)
for i := range tooMany {
tooMany[i] = "00000000-0000-0000-0000-000000000002"
}
tests := []struct {
name string
workspaceID string
body any
}{
{name: "malformed workspace", workspaceID: "not-a-uuid", body: map[string]any{"issue_ids": []string{}}},
{name: "malformed issue", workspaceID: workspaceID, body: map[string]any{"issue_ids": []string{"not-a-uuid"}}},
{name: "too many issues", workspaceID: workspaceID, body: map[string]any{"issue_ids": tooMany}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := newDaemonTokenRequest("POST", "/api/daemon/workspaces/"+tt.workspaceID+"/issues/gc-check", tt.body,
tt.workspaceID, "test-daemon")
req = withURLParam(req, "workspaceId", tt.workspaceID)
testutil.Call(t, h.BatchIssueGCCheck, req).Want(http.StatusBadRequest)
})
}
}
func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil)
req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{
"skill_ids": []string{"not-a-uuid"},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.SetAgentSkills, req).Want(http.StatusBadRequest)
}
func TestAddAgentSkillsPreservesExistingAssignments(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Skill Preserves Existing", nil)
existingSkillID := insertHandlerTestSkill(t, "add-preserve-existing", "existing body")
newSkillID := insertHandlerTestSkill(t, "add-preserve-new", "new body")
dbfx.Exec(t,
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
agentID, existingSkillID,
)
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{newSkillID},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusOK)
var resp []SkillSummaryResponse
w.JSON(&resp)
assertSkillIDsPresent(t, resp, existingSkillID, newSkillID)
assertAgentSkillRowCount(t, agentID, 2)
}
func TestAddAgentSkillsAddsMultipleAndIsIdempotent(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Multiple Skills", nil)
skillA := insertHandlerTestSkill(t, "add-multiple-a", "a body")
skillB := insertHandlerTestSkill(t, "add-multiple-b", "b body")
for attempt := 0; attempt < 2; attempt++ {
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{skillA, skillB},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.AddAgentSkills, req)
if w.Code != http.StatusOK {
t.Fatalf("AddAgentSkills attempt %d: expected 200, got %d: %s", attempt+1, w.Code, w.Body.String())
}
}
assertAgentSkillRowCount(t, agentID, 2)
}
func TestAddAgentSkillsRejectsMalformedSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Malformed Skill Assignment", nil)
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{"not-a-uuid"},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusBadRequest)
}
func TestAddAgentSkillsRejectsCrossWorkspaceSkillID(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Add Cross Workspace Skill", nil)
foreignSkillID := insertHandlerTestSkillInForeignWorkspace(t, "add-cross-workspace", "foreign body")
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
"skill_ids": []string{foreignSkillID},
})
req = withURLParam(req, "id", agentID)
testutil.Call(t, testHandler.AddAgentSkills, req).Want(http.StatusNotFound)
assertAgentSkillRowCount(t, agentID, 0)
}
func insertHandlerTestSkillInForeignWorkspace(t *testing.T, namePrefix, content string) string {
t.Helper()
slug := "foreign-skill-" + strings.ToLower(strings.ReplaceAll(t.Name(), "_", "-"))
workspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Foreign Skill Workspace " + t.Name(),
"slug": slug,
"description": "",
"issue_prefix": "FSW",
})
name := namePrefix + "-" + t.Name()
skillID := dbfx.Insert(t, "skill", testutil.Cols{
"workspace_id": workspaceID,
"name": name,
"description": "fixture",
"content": content,
"config": testutil.Raw("'{}'::jsonb"),
"created_by": testUserID,
})
return skillID
}
func assertSkillIDsPresent(t *testing.T, skills []SkillSummaryResponse, wantIDs ...string) {
t.Helper()
got := make(map[string]bool, len(skills))
for _, s := range skills {
got[s.ID] = true
}
for _, want := range wantIDs {
if !got[want] {
t.Fatalf("response missing skill %s; got %+v", want, skills)
}
}
}
func assertAgentSkillRowCount(t *testing.T, agentID string, want int) {
t.Helper()
var got int
dbfx.QueryRow(t,
`SELECT COUNT(*) FROM agent_skill WHERE agent_id = $1`,
agentID,
).Scan(&got)
if got != want {
t.Fatalf("agent_skill row count: got %d, want %d", got, want)
}
}
func TestAgentCRUD(t *testing.T) {
// List agents
w := testutil.Call(t, testHandler.ListAgents, newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)).Want(http.StatusOK)
var agents []AgentResponse
json.NewDecoder(w.Body).Decode(&agents)
if len(agents) == 0 {
t.Fatal("ListAgents: expected at least 1 agent")
}
// Update agent status
agentID := agents[0].ID
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"status": "idle",
})
req = withURLParam(req, "id", agentID)
w = testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
json.NewDecoder(w.Body).Decode(&updated)
if updated.Status != "idle" {
t.Fatalf("UpdateAgent: expected status 'idle', got '%s'", updated.Status)
}
if updated.Name != agents[0].Name {
t.Fatalf("UpdateAgent: name should be preserved, got '%s'", updated.Name)
}
}
func TestUpdateAgentMcpConfigAbsentPreservesValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Preserve", []byte(`{"preset":"keep"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"name": "Handler Mcp Preserve Updated",
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `{"preset":"keep"}`)
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"keep"}`)
}
func TestUpdateAgentMcpConfigNullClearsValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Clear", []byte(`{"preset":"clear"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"mcp_config": nil,
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `null`)
if fetchAgentMcpConfig(t, agentID) != nil {
t.Fatalf("UpdateAgent: expected DB mcp_config to be SQL NULL")
}
}
func TestUpdateAgentMcpConfigObjectUpdatesValue(t *testing.T) {
agentID := createHandlerTestAgent(t, "Handler Mcp Update", []byte(`{"preset":"old"}`))
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
"mcp_config": map[string]any{"preset": "new"},
})
req = withURLParam(req, "id", agentID)
w := testutil.Call(t, testHandler.UpdateAgent, req).Want(http.StatusOK)
var updated AgentResponse
w.JSON(&updated)
assertJSONEqual(t, updated.McpConfig, `{"preset":"new"}`)
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"new"}`)
}
func TestCreateAgentMcpConfigNullStoresSQLNull(t *testing.T) {
req := newRequest("POST", "/api/agents", map[string]any{
"name": "Handler Mcp Create Null",
"runtime_id": handlerTestRuntimeID(t),
"mcp_config": nil,
"custom_env": map[string]string{},
"custom_args": []string{},
})
w := testutil.Call(t, testHandler.CreateAgent, req).Want(http.StatusCreated)
var created AgentResponse
w.JSON(&created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, created.ID)
})
assertJSONEqual(t, created.McpConfig, `null`)
if fetchAgentMcpConfig(t, created.ID) != nil {
t.Fatalf("CreateAgent: expected DB mcp_config to be SQL NULL")
}
}
func TestWorkspaceCRUD(t *testing.T) {
// List workspaces
w := testutil.Call(t, testHandler.ListWorkspaces, newRequest("GET", "/api/workspaces", nil)).Want(http.StatusOK)
var workspaces []WorkspaceResponse
json.NewDecoder(w.Body).Decode(&workspaces)
if len(workspaces) == 0 {
t.Fatal("ListWorkspaces: expected at least 1 workspace")
}
// Get workspace
wsID := workspaces[0].ID
req := newRequest("GET", "/api/workspaces/"+wsID, nil)
req = withURLParam(req, "id", wsID)
w = testutil.Call(t, testHandler.GetWorkspace, req).Want(http.StatusOK)
}
func TestCreateWorkspaceUsesRequestedSlug(t *testing.T) {
const slug = "handler-create-workspace-requested"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
})
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Handler Create Workspace Requested",
"slug": slug,
})
w := testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusCreated)
var created WorkspaceResponse
w.JSON(&created)
if created.Slug != slug {
t.Fatalf("CreateWorkspace: expected slug %q, got %q", slug, created.Slug)
}
}
func TestCreateWorkspaceSlugConflictReturnsConflict(t *testing.T) {
ctx := context.Background()
retriedSlug := handlerTestWorkspaceSlug + "-2"
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, retriedSlug)
})
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Duplicate Handler Workspace",
"slug": handlerTestWorkspaceSlug,
})
testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusConflict)
var count int
dbfx.QueryRow(t, `SELECT count(*) FROM workspace WHERE slug = $1`, retriedSlug).Scan(&count)
if count != 0 {
t.Fatalf("CreateWorkspace: expected no fallback slug %q, got %d rows", retriedSlug, count)
}
}
func TestCreateWorkspaceInvalidSlugReturnsBadRequest(t *testing.T) {
req := newRequest("POST", "/api/workspaces", map[string]string{
"name": "Invalid Slug Workspace",
"slug": "invalid slug",
})
testutil.Call(t, testHandler.CreateWorkspace, req).Want(http.StatusBadRequest)
}
func TestSendCode(t *testing.T) {
w := httptest.NewRecorder()
body := map[string]string{"email": "[email protected]"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["message"] == "" {
t.Fatal("SendCode: expected non-empty message")
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, "[email protected]")
})
}
func TestSendCodeDbError(t *testing.T) {
// We can't easily mock the DB here without changing architecture,
// but we can simulate a DB error by closing the pool temporarily or
// using a cancelled context if the query respects it.
// Create a handler with a "broken" queries object is hard because it's a struct.
// Instead, let's use a context that is already cancelled.
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := httptest.NewRecorder()
body := map[string]string{"email": "[email protected]"}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(ctx)
testHandler.SendCode(w, req)
// If the DB query respects the cancelled context, it should return an error.
// pgx usually returns context.Canceled which is not what isNotFound checks for.
if w.Code != http.StatusInternalServerError {
t.Fatalf("SendCode (db error): expected 500, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "failed to lookup user" {
t.Fatalf("SendCode (db error): expected error message 'failed to lookup user', got '%s'", resp["error"])
}
}
func TestSendCodeRateLimit(t *testing.T) {
const email = "[email protected]"
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM verification_code WHERE email = $1`, email)
})
// First request should succeed
w := httptest.NewRecorder()
body := map[string]string{"email": email}
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(body)
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode (first): expected 200, got %d: %s", w.Code, w.Body.String())
}
// Second request within 60s should be rate limited
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(body)
req = httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("SendCode (second): expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCode(t *testing.T) {
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err == nil {
workspaces, listErr := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if listErr == nil {
for _, workspace := range workspaces {
_ = testHandler.Queries.DeleteWorkspace(ctx, workspace.ID)
}
}
}
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
// Send code first
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify with correct code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp LoginResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Token == "" {
t.Fatal("VerifyCode: expected non-empty token")
}
if resp.User.Email != email {
t.Fatalf("VerifyCode: expected email '%s', got '%s'", email, resp.User.Email)
}
}
func createVerificationCodeForTest(t *testing.T, email, code string) {
t.Helper()
_, err := testPool.Exec(context.Background(), `
INSERT INTO verification_code (email, code, expires_at)
VALUES ($1, $2, now() + interval '10 minutes')
`, email, code)
if err != nil {
t.Fatalf("create verification code: %v", err)
}
}
func TestVerifyCodeRejectsDevCodeUnlessExplicitlyConfigured(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
t.Setenv("APP_ENV", "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (disabled dev code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeAcceptsConfiguredDevCodeOutsideProduction(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "888888")
t.Setenv("APP_ENV", "development")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode (enabled dev code): expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeRejectsConfiguredDevCodeInProduction(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "888888")
t.Setenv("APP_ENV", "production")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
createVerificationCodeForTest(t, email, "123456")
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "888888"})
req := httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (production dev code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeWrongCode(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Verify with wrong code
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("VerifyCode (wrong code): expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeBruteForceProtection(t *testing.T) {
t.Setenv(devVerificationCodeEnv, "")
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("SendCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read actual code so we can try it after lockout
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Exhaust all 5 attempts with wrong codes
for i := 0; i < 5; i++ {
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": "000000"})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("attempt %d: expected 400, got %d", i+1, w.Code)
}
}
// Now even the correct code should be rejected (code is locked out)
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("after lockout: expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
const email = "[email protected]"
ctx := context.Background()
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM verification_code WHERE email = $1`, email)
testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
})
// Send code
w := httptest.NewRecorder()
var buf bytes.Buffer
json.NewEncoder(&buf).Encode(map[string]string{"email": email})
req := httptest.NewRequest("POST", "/auth/send-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.SendCode(w, req)
// Read code from DB
dbCode, err := testHandler.Queries.GetLatestVerificationCode(ctx, email)
if err != nil {
t.Fatalf("GetLatestVerificationCode: %v", err)
}
// Verify
w = httptest.NewRecorder()
buf.Reset()
json.NewEncoder(&buf).Encode(map[string]string{"email": email, "code": dbCode.Code})
req = httptest.NewRequest("POST", "/auth/verify-code", &buf)
req.Header.Set("Content-Type", "application/json")
testHandler.VerifyCode(w, req)
if w.Code != http.StatusOK {
t.Fatalf("VerifyCode: expected 200, got %d: %s", w.Code, w.Body.String())
}
user, err := testHandler.Queries.GetUserByEmail(ctx, email)
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
// New users should have no workspaces (/workspaces/new creates one)
workspaces, err := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("ListWorkspaces: expected 0 workspaces for new user, got %d", len(workspaces))
}
}
func TestResolveActor(t *testing.T) {
ctx := context.Background()
// Look up the agent created by the test fixture.
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create a task for the agent so we can test X-Task-ID validation.
var issueID string
err = testPool.QueryRow(ctx,
`INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number, position)
VALUES ($1, 'resolveActor test', 'todo', 'none', 'member', $2, 9999, 0)
RETURNING id`, testWorkspaceID, testUserID,
).Scan(&issueID)
if err != nil {
t.Fatalf("failed to create test issue: %v", err)
}
// Look up runtime_id for the agent.
var runtimeID string
err = testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID)
if err != nil {
t.Fatalf("failed to get agent runtime_id: %v", err)
}
var taskID string
err = testPool.QueryRow(ctx,
`INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id`, agentID, runtimeID, issueID,
).Scan(&taskID)
if err != nil {
t.Fatalf("failed to create test task: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
tests := []struct {
name string
agentIDHeader string
taskIDHeader string
wantActorType string
wantIsAgent bool
}{
{
name: "no headers returns member",
wantActorType: "member",
},
{
// X-Agent-ID without X-Task-ID is not trusted — otherwise a
// workspace member who guesses an agent's UUID could impersonate
// it and bypass the private-agent gate. See resolveActor for the
// rationale.
name: "agent ID without task ID returns member",
agentIDHeader: agentID,
wantActorType: "member",
},
{
name: "non-existent agent ID with task returns member",
agentIDHeader: "00000000-0000-0000-0000-000000000099",
taskIDHeader: taskID,
wantActorType: "member",
},
{
name: "valid agent + valid task returns agent",
agentIDHeader: agentID,
taskIDHeader: taskID,
wantActorType: "agent",
wantIsAgent: true,
},
{
name: "valid agent + wrong task returns member",
agentIDHeader: agentID,
taskIDHeader: "00000000-0000-0000-0000-000000000099",
wantActorType: "member",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := newRequest("GET", "/test", nil)
if tt.agentIDHeader != "" {
req.Header.Set("X-Agent-ID", tt.agentIDHeader)
}
if tt.taskIDHeader != "" {
req.Header.Set("X-Task-ID", tt.taskIDHeader)
}
actorType, actorID := testHandler.resolveActor(req, testUserID, testWorkspaceID)
if actorType != tt.wantActorType {
t.Errorf("actorType = %q, want %q", actorType, tt.wantActorType)
}
if tt.wantIsAgent {
if actorID != tt.agentIDHeader {
t.Errorf("actorID = %q, want agent %q", actorID, tt.agentIDHeader)
}
} else {
if actorID != testUserID {
t.Errorf("actorID = %q, want user %q", actorID, testUserID)
}
}
})
}
}
// TestBacklogNoTriggerOnCreate verifies that creating a backlog issue with an
// agent assignee does NOT enqueue a task — backlog is a parking lot.
func TestBacklogNoTriggerOnCreate(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog no-trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`,
created.ID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected no tasks for backlog issue on creation, got %d", taskCount)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoTriggersAgent verifies that moving an agent-assigned issue
// from "backlog" to "todo" enqueues exactly one agent task (none on creation,
// one on status transition).
func TestBacklogToTodoTriggersAgent(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create a backlog issue assigned to the agent — should NOT trigger.
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
// Move the issue from backlog to todo — should trigger.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"status": "todo",
})
req = withURLParam(req, "id", created.ID)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
// Verify exactly one task was enqueued (from the status transition, not creation).
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, agentID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 1 {
t.Fatalf("expected exactly 1 task after backlog->todo transition, got %d", taskCount)
}
// Cleanup
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoByAgentTriggersDifferentAssignee verifies that the
// documented sub-task chain works: when an agent (parent / Step 1) promotes
// a backlog issue assigned to a different agent (child / Step 2), the
// child's task is enqueued. Previously the backlog→active trigger was
// gated on `actorType == "member"`, which silently dropped agent-driven
// promotions and broke the serial sub-task workflow.
func TestBacklogToTodoByAgentTriggersDifferentAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Parent agent (the actor) + child agent (the assignee).
parentAgent := createHandlerTestAgent(t, "Backlog Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Backlog Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
// Create a backlog issue assigned to the child agent — should NOT trigger
// on creation (backlog parking-lot rule).
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Serial sub-task Step 2",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Parent agent promotes backlog → todo on behalf of the X-Task it is
// currently running. Must enqueue exactly one task for the child agent.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var childTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks)
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger verifies the
// task-issue-scoped self-loop guard: an agent whose CURRENT task is
// running on issue I and who flips I from backlog to an active status
// must NOT enqueue itself for I again. Without this guard the agent
// would re-trigger every cycle it completed on I and immediately
// re-enter the same path.
//
// This is the true self-loop case (calling task is on the SAME issue
// being promoted). The complementary case — same agent, DIFFERENT
// issue — is the documented serial chain and is covered by
// TestBacklogToTodoByAgentSameAgentDifferentIssue.
func TestBacklogToTodoByAgentSameIssueDoesNotSelfTrigger(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
selfAgent := createHandlerTestAgent(t, "Backlog Self Agent", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Self-promoted backlog",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": selfAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Task bound to the SAME issue being promoted — true self-loop.
selfTask := createHandlerTestTaskForAgentOnIssue(t, selfAgent, created.ID)
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", selfAgent)
req.Header.Set("X-Task-ID", selfTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var tasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, selfAgent,
).Scan(&tasks)
if tasks != 0 {
t.Fatalf("expected no self-trigger when agent promotes the same issue its task is running on, got %d queued tasks", tasks)
}
}
// TestBacklogToTodoByAgentSameAgentDifferentIssue verifies the documented
// same-agent serial chain still fires: when an agent is running a task on
// issue I1 and promotes a DIFFERENT backlog issue I2 (also assigned to
// itself), I2 must be enqueued. This was over-blocked by the previous
// agent-id-based self-loop guard, which made the same-agent serial
// workflow silently break.
func TestBacklogToTodoByAgentSameAgentDifferentIssue(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentID := createHandlerTestAgent(t, "Backlog Same-Agent Chain", nil)
// Step 1 issue — the one the agent is currently working on.
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 1 (running)",
"status": "in_progress",
"assignee_type": "agent",
"assignee_id": agentID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var step1 IssueResponse
json.NewDecoder(w.Body).Decode(&step1)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step1.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step1.ID)
})
// Step 2 issue — backlog, also assigned to the same agent.
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Step 2 (backlog)",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
w = testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var step2 IssueResponse
json.NewDecoder(w.Body).Decode(&step2)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, step2.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, step2.ID)
})
// Task is running on step1 — promoting step2 is NOT a self-loop.
step1Task := createHandlerTestTaskForAgentOnIssue(t, agentID, step1.ID)
req = newRequest("PUT", "/api/issues/"+step2.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", step2.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", step1Task)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var step2Tasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
step2.ID, agentID,
).Scan(&step2Tasks)
if step2Tasks != 1 {
t.Fatalf("expected exactly 1 task enqueued on step2 for same-agent serial chain, got %d", step2Tasks)
}
}
// TestAssignIssueToSelfWithActiveTargetRunDoesNotDuplicate covers the direct
// assignment form of #6947: an agent already working on an issue may claim its
// ownership, but that ownership write must not enqueue a second run for the
// same (issue, agent) pair.
func TestAssignIssueToSelfWithActiveTargetRunDoesNotDuplicate(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Self Assign Active Target", nil)
issue := createIssueForTest(t, map[string]any{"title": "self-assign active target", "status": "todo"})
runningTask := createHandlerTestTaskForAgentOnIssue(t, agentID, issue.ID)
previewReq := newRequest("POST", "/api/issues/preview-trigger?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{issue.ID},
"assignee_type": "agent",
"assignee_id": agentID,
})
previewReq.Header.Set("X-Agent-ID", agentID)
previewReq.Header.Set("X-Task-ID", runningTask)
previewRecorder := testutil.Call(t, testHandler.PreviewIssueTrigger, previewReq).Want(http.StatusOK)
var preview IssueTriggerPreviewResponse
previewRecorder.JSON(&preview)
if preview.TotalCount != 0 {
t.Fatalf("preview promised a duplicate self-assignment run: %+v", preview)
}
req := withURLParam(newRequest("PUT", "/api/issues/"+issue.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
}), "id", issue.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", runningTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, issue.ID, agentID); got != 0 {
t.Fatalf("self-assignment duplicated an active target run: got %d queued task(s)", got)
}
if got := taskStatus(t, runningTask); got != "running" {
t.Fatalf("existing target run must survive ownership claim, got status %q", got)
}
}
func TestShouldSuppressActiveSelfAssignmentFailsClosedOnLookupError(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
const agentID = "1c331d0b-94fd-412a-a7cc-6a209add00a1"
const issueID = "34c44eb2-bd85-455f-b47c-f39cc7b0b913"
ctx, cancel := context.WithCancel(context.Background())
cancel()
if !testHandler.shouldSuppressActiveSelfAssignment(
ctx,
"agent",
agentID,
parseUUID(issueID),
parseUUID(agentID),
) {
t.Fatal("lookup failure must fail closed and suppress duplicate enqueue")
}
}
// TestAssignDifferentIssueToSelfStillEnqueues locks in the intentional
// cross-issue handoff behavior: an agent working on I1 may assign fresh I2 to
// itself, and I2 still gets a queued run.
func TestAssignDifferentIssueToSelfStillEnqueues(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Cross Issue Self Assign", nil)
source := createIssueForTest(t, map[string]any{"title": "cross-issue source", "status": "todo"})
target := createIssueForTest(t, map[string]any{"title": "cross-issue target", "status": "todo"})
sourceTask := createHandlerTestTaskForAgentOnIssue(t, agentID, source.ID)
req := withURLParam(newRequest("PUT", "/api/issues/"+target.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
}), "id", target.ID)
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", sourceTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, target.ID, agentID); got != 1 {
t.Fatalf("cross-issue self-assignment should enqueue one run, got %d", got)
}
}
// TestBatchAssignFreshIssuesToSelfEnqueuesEach protects triage/autopilot
// batches: being busy on a source issue is not a global self-assignment ban.
// Every fresh target keeps the existing enqueue behavior.
func TestBatchAssignFreshIssuesToSelfEnqueuesEach(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
agentID := createHandlerTestAgent(t, "Batch Fresh Self Assign", nil)
source := createIssueForTest(t, map[string]any{"title": "batch source", "status": "todo"})
target1 := createIssueForTest(t, map[string]any{"title": "batch target one", "status": "todo"})
target2 := createIssueForTest(t, map[string]any{"title": "batch target two", "status": "todo"})
sourceTask := createHandlerTestTaskForAgentOnIssue(t, agentID, source.ID)
req := newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{target1.ID, target2.ID},
"updates": map[string]any{
"assignee_type": "agent",
"assignee_id": agentID,
},
})
req.Header.Set("X-Agent-ID", agentID)
req.Header.Set("X-Task-ID", sourceTask)
testutil.Call(t, testHandler.BatchUpdateIssues, req).Want(http.StatusOK)
for _, target := range []IssueResponse{target1, target2} {
if got := queuedTaskCountFor(t, target.ID, agentID); got != 1 {
t.Fatalf("fresh target %s should enqueue one run, got %d", target.ID, got)
}
}
}
// TestAssignActiveIssueToDifferentAgentStillEnqueues verifies the duplicate
// guard never turns a real agent-to-agent transfer into an ownership-only
// update. The old task survives per #4963 and the new assignee gets a run.
func TestAssignActiveIssueToDifferentAgentStillEnqueues(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
actorAgent := createHandlerTestAgent(t, "Active Transfer Actor", nil)
targetAgent := createHandlerTestAgent(t, "Active Transfer Target", nil)
issue := createIssueForTest(t, map[string]any{"title": "active transfer", "status": "todo"})
actorTask := createHandlerTestTaskForAgentOnIssue(t, actorAgent, issue.ID)
req := withURLParam(newRequest("PUT", "/api/issues/"+issue.ID, map[string]any{
"assignee_type": "agent",
"assignee_id": targetAgent,
}), "id", issue.ID)
req.Header.Set("X-Agent-ID", actorAgent)
req.Header.Set("X-Task-ID", actorTask)
testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
if got := queuedTaskCountFor(t, issue.ID, targetAgent); got != 1 {
t.Fatalf("new assignee should get one queued run, got %d", got)
}
if got := taskStatus(t, actorTask); got != "running" {
t.Fatalf("old active task must survive transfer, got status %q", got)
}
}
// TestBatchBacklogToTodoByAgentTriggersAssignee mirrors the single-update
// serial-chain test on the BatchUpdateIssues path. Earlier the
// member-only gate would silently drop agent-driven batch promotions; the
// task-issue self-loop guard must let cross-issue (same-agent) batch
// promotions through.
func TestBatchBacklogToTodoByAgentTriggersAssignee(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
parentAgent := createHandlerTestAgent(t, "Batch Parent Agent", nil)
childAgent := createHandlerTestAgent(t, "Batch Child Agent", nil)
parentTask := createHandlerTestTaskForAgent(t, parentAgent)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Batch backlog child",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": childAgent,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Drive the batch endpoint with the same agent identity headers.
req = newRequest("PATCH", "/api/issues/batch?workspace_id="+testWorkspaceID, map[string]any{
"issue_ids": []string{created.ID},
"updates": map[string]any{"status": "todo"},
})
req.Header.Set("X-Agent-ID", parentAgent)
req.Header.Set("X-Task-ID", parentTask)
w = testutil.Call(t, testHandler.BatchUpdateIssues, req).Want(http.StatusOK)
var childTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, childAgent,
).Scan(&childTasks)
if childTasks != 1 {
t.Fatalf("expected exactly 1 task enqueued for child agent after batch agent-driven backlog→todo, got %d", childTasks)
}
}
// TestBacklogToTodoByAgentTriggersSquadLeader covers the squad branch of
// the backlog→active trigger when the actor is an agent: the leader agent
// of a squad must wake when one of its squad-assigned backlog issues is
// promoted by another agent (or by the leader itself acting from a task
// on a different issue). The task-issue self-loop guard must allow this —
// only a true same-issue self-loop should be suppressed.
func TestBacklogToTodoByAgentTriggersSquadLeader(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
leaderAgent := createHandlerTestAgent(t, "Backlog Squad Leader", nil)
driverAgent := createHandlerTestAgent(t, "Backlog Squad Driver", nil)
driverTask := createHandlerTestTaskForAgent(t, driverAgent)
squadID := dbfx.Squad(t, "Backlog Trigger Squad", leaderAgent)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Squad backlog issue",
"status": "backlog",
"assignee_type": "squad",
"assignee_id": squadID,
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, created.ID)
})
// Driver agent (not the leader, task is on no specific issue) promotes
// the squad-assigned backlog issue. Squad leader must be enqueued.
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{"status": "todo"})
req = withURLParam(req, "id", created.ID)
req.Header.Set("X-Agent-ID", driverAgent)
req.Header.Set("X-Task-ID", driverTask)
w = testutil.Call(t, testHandler.UpdateIssue, req).Want(http.StatusOK)
var leaderTasks int
dbfx.QueryRow(t,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, leaderAgent,
).Scan(&leaderTasks)
if leaderTasks != 1 {
t.Fatalf("expected exactly 1 squad-leader task after agent-driven backlog→todo on squad issue, got %d", leaderTasks)
}
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{
"workspace_id":"00000000-0000-0000-0000-000000000001",
"daemon_id":"local-daemon",
"device_name":"test-machine",
"runtimes":[{"name":"Local Codex","type":"codex","version":"1.0.0","status":"online"}]
}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", testUserID)
w := testutil.Call(t, testHandler.DaemonRegister, req).Want(http.StatusNotFound)
if !strings.Contains(w.Body.String(), "workspace not found") {
t.Fatalf("DaemonRegister: expected workspace not found error, got %s", w.Body.String())
}
}
// TestRootMentionOwnerRoutesMemberReplyButNotAgentReply verifies that a member
// root @mention owns later member replies, while agent-authored acknowledgments
// still do not self-expand the route.
func TestRootMentionOwnerRoutesMemberReplyButNotAgentReply(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// Create two agents.
agentA := createHandlerTestAgent(t, "Loop Agent A", nil)
agentB := createHandlerTestAgent(t, "Loop Agent B", nil)
// Create an unassigned issue so on_comment doesn't fire.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent mention inheritance test",
"status": "todo",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
// Helper: count queued tasks for a given agent on this issue.
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// Helper: cancel all tasks for an agent on this issue.
cancelTasks := func(agentID string) {
_, err := testPool.Exec(ctx,
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
issueID, agentID,
)
if err != nil {
t.Fatalf("failed to cancel tasks: %v", err)
}
}
postComment := func(issueID string, body map[string]any, headers map[string]string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
for k, v := range headers {
r.Header.Set(k, v)
}
testHandler.CreateComment(w, r)
return w
}
// 1. Member posts top-level comment mentioning Agent B.
mentionB := fmt.Sprintf("[@Agent B](mention://agent/%s) please review", agentB)
w = postComment(issueID, map[string]any{"content": mentionB}, nil)
if w.Code != http.StatusCreated {
t.Fatalf("member mention comment: expected 201, got %d: %s", w.Code, w.Body.String())
}
var parentComment CommentResponse
json.NewDecoder(w.Body).Decode(&parentComment)
if countTasks(agentB) != 1 {
t.Fatalf("expected 1 task for Agent B after member mention, got %d", countTasks(agentB))
}
// 2. Cancel Agent B's task so it's free to be re-triggered.
cancelTasks(agentB)
if countTasks(agentB) != 0 {
t.Fatalf("expected 0 tasks for Agent B after cancel, got %d", countTasks(agentB))
}
// 3. Agent A posts a reply in the same thread with NO mentions.
// Agent-authored comments still do not inherit the root mention of Agent B.
// resolveActor requires X-Task-ID paired with X-Agent-ID to trust the
// agent identity, so we seed a task that belongs to agent A.
agentATask := createHandlerTestTaskForAgent(t, agentA)
w = postComment(issueID, map[string]any{
"content": "No reply needed — just an acknowledgment.",
"parent_id": parentComment.ID,
}, map[string]string{"X-Agent-ID": agentA, "X-Task-ID": agentATask})
if w.Code != http.StatusCreated {
t.Fatalf("agent A reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
if countTasks(agentB) != 0 {
t.Fatalf("expected 0 tasks for Agent B after agent reply (no parent inheritance), got %d", countTasks(agentB))
}
// 4. Cancel any stray tasks.
cancelTasks(agentB)
// 5. Member posts a reply in the same thread with NO mentions.
// The member-authored root @mention owns the thread, so this routes to B.
w = postComment(issueID, map[string]any{
"content": "Thanks for the review.",
"parent_id": parentComment.ID,
}, nil)
if w.Code != http.StatusCreated {
t.Fatalf("member reply: expected 201, got %d: %s", w.Code, w.Body.String())
}
if countTasks(agentB) != 1 {
t.Fatalf("expected 1 task for Agent B after member reply (root owner), got %d", countTasks(agentB))
}
}
// TestMemberReplyToAgentRootDoesNotInheritParentMentions is the regression
// for MUL-1535. When an agent posts a comment that @mentions another agent
// (e.g. J posting a PR completion that @mentions a reviewer agent), a later
// member reply in the same thread with no explicit mentions must NOT inherit
// the @reviewer mention. The reviewer was a one-shot delegation; subsequent
// member follow-ups are directed at the assignee, not the reviewer.
func TestMemberReplyToAgentRootDoesNotInheritParentMentions(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
jAgent := createHandlerTestAgent(t, "J", nil)
reviewerAgent := createHandlerTestAgent(t, "Reviewer", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "PR review delegation no-leak test",
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// 1. Agent J posts a PR-completion comment that @mentions Reviewer for review.
// This is a deliberate handoff and must enqueue a task for Reviewer.
// X-Task-ID is required alongside X-Agent-ID for resolveActor to grant
// the "agent" actor identity (defense against header forgery).
jAgentTask := createHandlerTestTaskForAgent(t, jAgent)
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": fmt.Sprintf("PR ready. [@Reviewer](mention://agent/%s) please review this.", reviewerAgent),
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", jAgent)
r.Header.Set("X-Task-ID", jAgentTask)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var rootComment CommentResponse
json.NewDecoder(w.Body).Decode(&rootComment)
if got := countTasks(reviewerAgent); got != 1 {
t.Fatalf("expected 1 task for Reviewer after explicit mention, got %d", got)
}
// Cancel reviewer's task so it's free to be re-triggered if the bug returns.
dbfx.Exec(t,
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
issueID, reviewerAgent,
)
// 2. Member posts a plain follow-up reply under J's PR comment, with no
// explicit mentions. The pre-fix code path inherited mentions from the
// parent regardless of the parent author, which re-triggered Reviewer.
// With the fix, the reply must NOT inherit because the parent was
// authored by an agent.
r = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": "How do I test this after merging?",
"parent_id": rootComment.ID,
})
r = withURLParam(r, "id", issueID)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
if got := countTasks(reviewerAgent); got != 0 {
t.Fatalf("expected 0 tasks for Reviewer after plain member reply (no inheritance from agent root), got %d", got)
}
}
// TestNestedMemberReplyUsesDirectParentForThreadOwnership is the regression
// for parent-root write normalization leaking root mentions into plain nested
// replies. Stored parent_id keeps the direct parent, and trigger logic routes
// to that direct agent parent rather than the thread root's explicit mention.
func TestNestedMemberReplyUsesDirectParentForMentionInheritance(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
assigneeAgent := createHandlerTestAgent(t, "Nested Mention Assignee", nil)
mentionedAgent := createHandlerTestAgent(t, "Nested Mention Target", nil)
parentAgent := createHandlerTestAgent(t, "Nested Direct Parent", nil)
var number int
dbfx.QueryRow(t, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number)
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "nested mention inheritance regression",
"assignee_type": "agent",
"assignee_id": assigneeAgent,
"number": number,
})
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
countQueued := func(agentID string) int {
t.Helper()
var n int
dbfx.QueryRow(t, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
`, issueID, agentID).Scan(&n)
return n
}
postMemberComment := func(body map[string]any) CommentResponse {
t.Helper()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var resp CommentResponse
w.JSON(&resp)
return resp
}
root := postMemberComment(map[string]any{
"content": fmt.Sprintf("[@Mentioned](mention://agent/%s) please look", mentionedAgent),
})
if got := countQueued(mentionedAgent); got != 1 {
t.Fatalf("expected root mention to queue mentioned agent once, got %d", got)
}
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1`, issueID)
directParentID := dbfx.Comment(t, issueID, "looks like redirect config", testutil.Cols{
"author_type": "agent",
"author_id": parentAgent,
"parent_id": root.ID,
})
nested := postMemberComment(map[string]any{
"content": "can you also check session expiry?",
"parent_id": directParentID,
})
if nested.ParentID == nil || *nested.ParentID != directParentID {
t.Fatalf("stored nested reply parent_id should keep direct parent %s, got %v", directParentID, nested.ParentID)
}
if got := countQueued(mentionedAgent); got != 0 {
t.Fatalf("plain nested reply must not inherit root mention from non-direct parent; got %d queued tasks", got)
}
if got := countQueued(parentAgent); got != 1 {
t.Fatalf("plain nested reply should route to direct agent parent; got %d queued tasks", got)
}
}
// TestNestedMemberReplyUnderMemberSkipsAssigneeFallback verifies that a nested
// reply whose direct parent is human-owned neither routes to a sibling agent
// reply nor falls back to the issue assignee. A sibling agent comment alone
// does not establish a conversation owner for the member-authored root.
func TestNestedMemberReplyUnderMemberSkipsAssigneeFallback(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
assigneeAgent := createHandlerTestAgent(t, "Nested Participation Assignee", nil)
var number int
dbfx.QueryRow(t, `
UPDATE workspace
SET issue_counter = GREATEST(issue_counter, (SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)) + 1
WHERE id = $1 RETURNING issue_counter
`, testWorkspaceID).Scan(&number)
issueID := dbfx.Insert(t, "issue", testutil.Cols{
"workspace_id": testWorkspaceID,
"creator_type": "member",
"creator_id": testUserID,
"title": "nested participation regression",
"assignee_type": "agent",
"assignee_id": assigneeAgent,
"number": number,
})
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
countAssigneeQueued := func() int {
t.Helper()
var n int
dbfx.QueryRow(t, `
SELECT count(*) FROM agent_task_queue
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
`, issueID, assigneeAgent).Scan(&n)
return n
}
postMemberComment := func(body map[string]any) CommentResponse {
t.Helper()
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
r = withURLParam(r, "id", issueID)
w := testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
var resp CommentResponse
w.JSON(&resp)
return resp
}
rootID := dbfx.Comment(t, issueID, "this cache question is for humans")
dbfx.Exec(t, `
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, parent_id)
VALUES ($1, $2, 'agent', $3, 'expiration policy is the issue', $4)
`, testWorkspaceID, issueID, assigneeAgent, rootID)
humanParentID := dbfx.Comment(t, issueID, "I have seen this too", testutil.Cols{
"parent_id": rootID,
})
nested := postMemberComment(map[string]any{
"content": "what should the expiration be?",
"parent_id": humanParentID,
})
if nested.ParentID == nil || *nested.ParentID != humanParentID {
t.Fatalf("stored nested reply parent_id should keep direct parent %s, got %v", humanParentID, nested.ParentID)
}
if got := countAssigneeQueued(); got != 0 {
t.Fatalf("plain nested human reply queued assignee tasks = %d, want 0", got)
}
}
// TestAgentExplicitMentionStillTriggers documents the boundary the structural
// fix preserves: suppressing implicit parent-mention inheritance for agent
// authors does NOT block deliberate handoffs. An agent that explicitly
// @mentions another agent in its own comment content still enqueues a task
// for that mentioned agent.
func TestAgentExplicitMentionStillTriggers(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
agentA := createHandlerTestAgent(t, "Handoff Agent A", nil)
agentB := createHandlerTestAgent(t, "Handoff Agent B", nil)
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Agent explicit handoff test",
"status": "todo",
})
w := testutil.Call(t, testHandler.CreateIssue, req).Want(http.StatusCreated)
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
issueID := issue.ID
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM comment WHERE issue_id = $1`, issueID)
testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID)
})
countTasks := func(agentID string) int {
var n int
err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
issueID, agentID,
).Scan(&n)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
return n
}
// Agent A posts a top-level comment that explicitly @mentions Agent B —
// a deliberate handoff. This must enqueue a task for Agent B, and must
// not enqueue a self-trigger for Agent A. resolveActor requires
// X-Task-ID to grant "agent" identity; without it the self-trigger
// suppression (authorType=="agent") would not fire.
agentATask := createHandlerTestTaskForAgent(t, agentA)
explicitMention := fmt.Sprintf("[@Agent B](mention://agent/%s) please take it from here", agentB)
r := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": explicitMention,
})
r = withURLParam(r, "id", issueID)
r.Header.Set("X-Agent-ID", agentA)
r.Header.Set("X-Task-ID", agentATask)
w = testutil.Call(t, testHandler.CreateComment, r).Want(http.StatusCreated)
if got := countTasks(agentB); got != 1 {
t.Fatalf("expected 1 task for Agent B after explicit mention by Agent A, got %d", got)
}
if got := countTasks(agentA); got != 0 {
t.Fatalf("expected 0 tasks for Agent A (no self-trigger on own mention), got %d", got)
}
}
func TestCreateSkillSkipsSkillMdFile(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-create-skip-skillmd",
Content: "# SKILL.md content",
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "readme"},
{Path: "SKILL.md", Content: "should be skipped"},
{Path: "helper.go", Content: "package main"},
},
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var resp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
// Should only have README.md and helper.go, not SKILL.md
if len(resp.Files) != 2 {
t.Fatalf("expected 2 files, got %d", len(resp.Files))
}
for _, f := range resp.Files {
if strings.EqualFold(f.Path, "SKILL.md") {
t.Fatalf("SKILL.md should not be in response files")
}
}
// Verify DB state directly
ctx := context.Background()
rows, err := testPool.Query(ctx, "SELECT path FROM skill_file WHERE skill_id = $1", resp.ID)
if err != nil {
t.Fatalf("query skill_file: %v", err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatalf("scan path: %v", err)
}
paths = append(paths, p)
}
if len(paths) != 2 {
t.Fatalf("expected 2 rows in skill_file, got %d", len(paths))
}
for _, p := range paths {
if strings.EqualFold(p, "SKILL.md") {
t.Fatalf("SKILL.md should not be stored in skill_file")
}
}
}
func TestUpdateSkillSkipsSkillMdFile(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
// Create a skill first
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-update-skip-skillmd",
Content: "# SKILL.md content",
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "readme"},
},
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create skill: expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var createResp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &createResp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
// Update with SKILL.md in files
updateReq := newRequest(http.MethodPut, "/api/skills/"+createResp.ID, UpdateSkillRequest{
Name: strPtr("updated-name"),
Content: strPtr("updated content"),
Files: []CreateSkillFileRequest{
{Path: "README.md", Content: "updated readme"},
{Path: "SKILL.md", Content: "should be skipped"},
{Path: "new.go", Content: "package main"},
},
})
updateReq = withURLParam(updateReq, "id", createResp.ID)
updateRec := httptest.NewRecorder()
testHandler.UpdateSkill(updateRec, updateReq)
if updateRec.Code != http.StatusOK {
t.Fatalf("update skill: expected 200, got %d: %s", updateRec.Code, updateRec.Body.String())
}
var updateResp SkillWithFilesResponse
if err := json.Unmarshal(updateRec.Body.Bytes(), &updateResp); err != nil {
t.Fatalf("unmarshal update response: %v", err)
}
if len(updateResp.Files) != 2 {
t.Fatalf("expected 2 files after update, got %d", len(updateResp.Files))
}
for _, f := range updateResp.Files {
if strings.EqualFold(f.Path, "SKILL.md") {
t.Fatalf("SKILL.md should not be in updated response files")
}
}
// Verify DB state
ctx := context.Background()
rows, err := testPool.Query(ctx, "SELECT path FROM skill_file WHERE skill_id = $1", createResp.ID)
if err != nil {
t.Fatalf("query skill_file: %v", err)
}
defer rows.Close()
var paths []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
t.Fatalf("scan path: %v", err)
}
paths = append(paths, p)
}
if len(paths) != 2 {
t.Fatalf("expected 2 rows in skill_file after update, got %d", len(paths))
}
for _, p := range paths {
if strings.EqualFold(p, "SKILL.md") {
t.Fatalf("SKILL.md should not be stored in skill_file after update")
}
}
}
func strPtr(s string) *string {
return &s
}
func TestUpsertSkillFileRejectsSkillMd(t *testing.T) {
if testPool == nil {
t.Skip("no database available")
}
// Create a skill first
req := newRequest(http.MethodPost, "/api/workspaces/"+testWorkspaceID+"/skills", CreateSkillRequest{
Name: "test-skill-upsert-reject-skillmd",
Content: "# SKILL.md content",
})
rec := httptest.NewRecorder()
testHandler.CreateSkill(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create skill: expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var createResp SkillWithFilesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &createResp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
// Try to upsert SKILL.md
upsertReq := newRequest(http.MethodPut, "/api/skills/"+createResp.ID+"/files", CreateSkillFileRequest{
Path: "SKILL.md",
Content: "should be rejected",
})
upsertReq = withURLParam(upsertReq, "id", createResp.ID)
upsertRec := httptest.NewRecorder()
testHandler.UpsertSkillFile(upsertRec, upsertReq)
if upsertRec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", upsertRec.Code, upsertRec.Body.String())
}
if !strings.Contains(upsertRec.Body.String(), "SKILL.md is reserved") {
t.Fatalf("expected error message about reserved SKILL.md, got: %s", upsertRec.Body.String())
}
}