mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720) (#4608)
* feat(composio): server-side connect flow + connections REST (Notion MVP) (MUL-3720)
Compose the merged server/pkg/composio SDK into a user-facing connection
manager: signed-state connect handshake, local user_composio_connection
mirror, idempotent disconnect, and a per-user MCP session helper (not yet
wired into task dispatch).
- migration 127_user_composio_connection (no FK/cascade, per DB rules)
- sqlc queries: upsert (idempotent on user_id+connected_account_id), list
active, owner-scoped get, mark revoked
- internal/integrations/composio: signed HMAC-SHA256 state, BeginConnect,
CompleteCallback (idempotent upsert), ListConnections, Disconnect
(upstream 404 = idempotent success), CreateMCPSession (no-op when empty,
pins connected_accounts per toolkit), CallbackRedirect
- REST handlers under /api/integrations/composio (user-scoped, 503 when
COMPOSIO_API_KEY unset): connect/init, callback (302), connections list,
delete
- router wiring gated by COMPOSIO_API_KEY; COMPOSIO_AUTH_CONFIGS_JSON maps
toolkit->auth_config (MVP: notion); state secret from COMPOSIO_STATE_SECRET
or derived from JWT_SECRET; callback base from COMPOSIO_CALLBACK_BASE_URL
or MULTICA_PUBLIC_URL
- tests: state (expire/tamper/wrong-secret), service (mapping, callback
idempotency, non-success, disconnect owner/404 idempotency, MCP pin),
handlers (httptest), redact regression for Bearer mcp_ tokens
MVP scope: Notion only; no task-dispatch overlay, sharing, or webhook
event handling (later stages).
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): bind callback account to user + idempotent revoked disconnect (MUL-3720)
Address PR 4608 review (CHANGES_REQUESTED):
- callback: verify connected_account_id with Composio before mirroring it.
The signed state only proved user/toolkit/exp, so a valid state paired with
a tampered connected_account_id would be written verbatim. CompleteCallback
now calls ListConnectedAccounts and fails closed (ErrAccountVerification)
unless the account belongs to the state's user (composio_user_id == multica
user id) and was created under the toolkit's auth config. No row is written
on mismatch / unknown account / upstream error.
- disconnect: short-circuit to a no-op when the local row is already revoked,
before touching upstream. Previously a second DELETE re-hit Composio and a
non-404 upstream error surfaced as a 502, breaking the 204-idempotent
contract.
- CreateMCPSession: document the v1 single-active-connection-per-(user,toolkit)
constraint and make duplicate selection deterministic (newest-wins, rows are
connected_at DESC) instead of order-dependent map overwrite. Stage 3 owns the
real single-account-enforcement vs multi-account-shape decision.
Tests: tampered/wrong-auth-config/unknown-account callback rejection, revoked-row
disconnect no-op (asserts upstream not re-hit). composio pkg 85% coverage; all
green.
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): list all toolkits + dynamic auth-config resolution (MUL-3720)
Yushen's follow-up to the Notion MVP: surface the full Composio toolkit
catalog, render it in Settings, and drop the static env mapping in favor of
dynamic auth-config discovery.
Config correctness (per Composio docs):
- Remove COMPOSIO_AUTH_CONFIGS_JSON entirely. The toolkit→auth_config mapping
is now resolved at request time from the project's /auth_configs (cached,
5-min TTL), so enabling a toolkit is a dashboard action, not a redeploy.
- Do NOT add COMPOSIO_PROJECT_ID. The project API key (x-api-key) authenticates
to exactly one project; the project is resolved from the key. Only org-level
endpoints use x-org-api-key, which this integration never calls.
Backend:
- SDK: server/pkg/composio/auth_configs.go — ListAuthConfigs (toolkit_slug,
is_composio_managed, show_disabled, limit, cursor).
- service: dynamic resolver (authConfigMap cache; betterAuthConfig prefers a
custom/white-label config over Composio-managed, newest wins); BeginConnect
and CompleteCallback resolve via it; ListToolkits fetches the full catalog
(paginated, capped) annotated with connectable = has an enabled auth config,
connectable-first ordering.
- handler + route: GET /api/integrations/composio/toolkits (user-scoped, 503
when COMPOSIO_API_KEY unset) returning slug/name/logo/category/connectable.
Frontend:
- core: ComposioToolkit/ComposioConnection types, api client methods, and
composio query options (@multica/core/composio).
- views: Settings → Integrations now has a Composio section rendering every
toolkit as a card with search. Connect is gated on `connectable`;
non-connectable toolkits show a muted "not configured" hint instead of a
dead button. Connected toolkits show a badge + Disconnect (with confirm).
- i18n: composio block added to en/zh-Hans/ja/ko settings.
Tests: SDK + service (dynamic resolution, custom-over-managed preference,
connectable flag, resolver-error soft-degrade) and handler toolkits endpoint;
composio pkg 85.7% coverage. go build/vet/gofmt clean; core+views typecheck,
core+views lint, and core tests (691) all green.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): close cross-toolkit callback fail-open by signing auth_config_id into state (MUL-3720)
Re-review blocker: CompleteCallback resolved the toolkit's auth config at
callback time and ignored a resolve error/empty result, while
verifyAccountOwnership skipped the auth-config comparison when the expected
value was empty. A user could then pass another toolkit's connected_account_id
into this toolkit's callback — the owner check passed and it was written under
the wrong toolkit_slug/account binding.
Fix: the auth_config_id is already resolved in BeginConnect (before the state
is signed), so sign it into the state and compare it exactly at callback. No
re-resolve, no fail-open. verifyAccountOwnership now fails closed when the
expected auth config is empty (rejects instead of skipping) and requires an
exact match — closing the cross-toolkit binding gap.
Tests: state round-trips auth_config_id; BeginConnect signs it; callback
rejects wrong/cross-toolkit auth config and an empty (no-mapping) auth config
fails closed. composio pkg 85.2% coverage, all green.
Frontend (non-blocking): the Composio settings tab now surfaces an error when
the connections query fails instead of silently rendering everything as
unconnected.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): hide Settings section entirely when integration unconfigured (MUL-3720)
Decision (option 2, hide-then-merge): don't show a card that leaks the internal
COMPOSIO_API_KEY env-var name to every end user. IntegrationsTab now gates the
whole Composio section (heading + body) on the toolkits query — a 503 means the
key is unset, so the section is withheld instead of rendering the not-configured
card. Admin-only setup guidance is a later, role-gated affordance.
Removed the notConfigured card (and now-unused ApiError import) from
ComposioTab; it only mounts when configured. views typecheck + lint clean.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): Stage 2 frontend polish — callback toast, last_used & expired UI, e2e (MUL-3718) (#4688)
* feat(composio): callback toast + refresh, last_used & expired UI, e2e (MUL-3718)
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): real callback redirect route + StrictMode-safe toast dedup (MUL-3718 review)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): callback endpoint should not require Multica auth (MUL-3843) (#4709)
* fix(composio): move OAuth callback out of the Auth group (MUL-3843)
Composio 302-redirects the browser to /api/integrations/composio/callback
at the end of the OAuth flow, but PR #4608 mounted it inside the cookie-auth
middleware group. When the session cookie is absent (expired session,
SameSite=Strict / Safari ITP, private window, self-hosted callback subdomain)
the Auth middleware returned a hard 401 and a JSON blob instead of the
settings redirect, breaking the flow.
Identity never came from the cookie anyway: it is carried by the HMAC-signed
state param that CompleteCallback verifies (signature, expiry, replay) and
cross-checked by verifyAccountOwnership; h.Composio == nil still 503s. So the
callback is registered alongside the other public OAuth/webhook routes; the
other four composio endpoints stay session-gated.
Refs MUL-3843, MUL-3715.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): correct stale callback routing comments (MUL-3843)
The package header and ComposioCallback doc comments still described the
callback as sitting under the Auth middleware group. After the route was
moved out (this PR), update both to state it is a public route whose identity
comes from the signed state — addressing review nit from 张大彪.
Refs MUL-3843.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): inject MCP overlay into agent runtime at task dispatch (MUL-3721) (#4704)
Stage 3 of the Composio epic. Wires the per-user Composio MCP session into
every agent task so the agent process sees the initiator's connected tools
without any prompt-time plumbing.
Server side
- Migration 128 adds agent_task_queue.runtime_mcp_overlay JSONB plus a
BEFORE-UPDATE trigger that wipes the column on any transition into a
terminal status (completed / failed / cancelled). A trigger is the single
source of truth — future queries that flip status cannot bypass it.
- composio.Service.BuildTaskOverlay(userID) reuses CreateMCPSession and
emits the Claude-style { mcpServers: { composio: { type: http, url,
headers } } } shape the daemon's existing sidecar generators consume.
Returns (nil, nil) on zero active connections so we never burn a
Composio session for a user with nothing to call.
- TaskService grows a Composio ComposioOverlayBuilder seam, wired in
router.go after composiointeg.NewService succeeds. Five enqueue paths
(issue / mention / quick-create / chat / auto-retry) attach the overlay
after CreateAgentTask returns and before the daemon is notified — so
every claim reads a settled row, with no second daemon hop. Best-effort:
a builder failure logs and proceeds with no overlay.
- resolveInitiatorFromTriggerComment derives the initiator user from the
trigger comment when it was authored by a member. Agent-authored
triggers are not treated as initiators (their connected-apps view is
empty by construction).
Daemon side
- handler/daemon.go claim path merges task.runtime_mcp_overlay onto
agent.mcp_config via mergeMCPOverlay before populating
TaskAgentData.McpConfig. Overlay wins on server-name collisions
because it carries the live user-scoped session URL. Errors fall back
to the agent config unchanged — a bad overlay must not surprise-disable
saved MCP tools. The existing execenv sidecar generators (cursor /
codex / openclaw / opencode / hermes-kiro) need no changes: they keep
consuming the merged result through TaskAgentData.McpConfig.
Tests
- 9 merge cases (mcp_overlay_test): both-nil short-circuit, agent-only
pass-through, overlay-only canonicalization, two-side merge, name
collision (overlay wins), top-level key preservation, malformed agent
fallback, malformed overlay fallback, non-object server rejection.
- 4 dispatch cases (composio): zero-connections returns nil without
CreateSession, happy-path emits the right shape with the right user
id, empty-URL defensive branch, SDK error surfacing.
- 4 TaskService helper cases: nil Composio is a no-op (Queries-safe),
invalid initiator does not call the builder, nil overlay skips the
UPDATE, builder error swallowed without panic.
- Migration 128 verified to roll up + down + up cleanly against the test
database.
Out of scope (deferred): assignment-triggered enqueue paths with no
trigger comment get no overlay attached today (no initiator UUID flows
through enqueueIssueTask in that case). Retry paths recompute the overlay
fresh from the parent's initiator_user_id instead of inheriting the bearer
from the parent row, so a stale token can never resurface on a retry.
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869) (#4736)
* feat(composio): per-agent allowlist + originator-scoped MCP overlay (MUL-3869)
Stage 3.1 of the Composio epic (MUL-3721 parent). PR #4704 wired in the
runtime_mcp_overlay column and a per-task dispatch hook; this change
inverts the default from "all-on" to opt-in and locks the overlay to the
agent owner's own connected apps:
- Agents carry composio_toolkit_allowlist TEXT[]. NULL or [] => no MCP.
Owner-only read/write; non-owner GET/PUT silently redacts/drops the
field (same shape as mcp_config).
- agent_task_queue carries originator_user_id UUID. Set from the
top-of-chain HUMAN at every enqueue path:
* issue/mention comment by member -> author_id
* issue/mention comment by agent -> inherit via comment.source_task_id
-> parent task originator_user_id
* quick-create -> requester_id
* chat -> initiator_user_id
* retry -> SQL-inherited from parent row
* autopilot -> NULL (system-driven)
- BuildTaskOverlay (composio dispatch) now takes (ctx, originatorUserID,
agent) and short-circuits on five gates: invalid originator,
originator != agent.owner_id, empty allowlist, empty intersection of
allowlist ∩ active connections, defensive empty session URL. Composio
CreateSession is called with BOTH `toolkits.slugs` (the intersection)
AND `connected_accounts` (the pinned account ids), narrowing the
tool-router twice.
- The originator-vs-owner gate closes the agent-fanout privacy hole: any
workspace member who can @-mention a public agent used to project the
owner's connected apps into their run. Now the overlay only mounts
when the human at the top of the chain IS the agent owner.
Tests:
- dispatch_test.go covers all 5 gates plus uppercase/whitespace slug
normalisation.
- task_runtime_mcp_overlay_test.go covers the no-op gates of the new
applyRuntimeMCPOverlay signature.
- agent_composio_allowlist_test.go (handler): owner roundtrip
(list/empty/null), workspace-admin silent-drop, owner-only GET
visibility, pure normaliseComposioToolkitAllowlist.
- resolve_originator_test.go (service, DB-backed): member-authored,
agent-authored inherits via comment.source_task_id, invalid id.
Migration 129 up/down/up verified against docker postgres.
Co-authored-by: multica-agent <github@multica.ai>
* chore(composio): gofmt + regenerate sqlc with v1.31.1 (MUL-3869 review nits)
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): accept nested connected account auth config
* feat(views): creator-only MCP tab for per-agent Composio allowlist (MUL-3870) (#4743)
Stage 3.2 frontend on top of the Stage 3.1 backend (MUL-3869, 4708dba97).
Adds an agent-detail tab that lets the agent owner pick which of their own
active Composio connections this agent may mount as MCP servers, writing the
selection to agent.composio_toolkit_allowlist via the existing PUT /api/agents.
- core/types: composio_toolkit_allowlist (+ _redacted) on Agent; tri-state
composio_toolkit_allowlist on UpdateAgentRequest (omit/no-change, null/clear,
array/replace), matching the backend contract.
- core/agents: useUpdateAgentAllowlist - optimistic mutation hook (patches the
cached workspace agent list, rolls back on error, invalidates on settle).
- views: AgentMcpTab renders the owner's active connections as checkboxes;
empty state links to Settings -> Integrations; defensive redacted state.
- views: wired into AgentOverviewPane as tab "composio_mcp", labeled "MCP Apps"
to disambiguate from the existing raw-JSON "MCP" (mcp_config) tab. The entry
is gated to the creator (currentUserId === agent.owner_id), matching the
backend's owner-only read/write of the allowlist.
- i18n: tabs.composio_mcp + tab_body.composio_mcp.* in en/ja/ko/zh-Hans.
- tests: agent-mcp-tab.test.tsx (gating, toggle->allowlist body, active-only,
empty, redacted); e2e/agent-mcp.spec.ts (creator sees tab + PUT body,
non-creator hidden) with Composio + agent endpoints mocked at the boundary.
Note: the product spec says "creator"; the schema has no creator_id - the
backend gate and redaction are keyed on owner_id, so the tab uses owner_id.
Co-authored-by: multica-agent <github@multica.ai>
* fix(composio): mount remote MCP for codex
* feat(agents): agent invocation permission system (MUL-3963) (#4844)
* feat(agents): agent invocation permission system (permission_mode + invocation targets)
MUL-3963: split who may INVOKE an agent out of the overloaded visibility
column into an explicit, extensible model on feature/composio-integration.
- DB: agent.permission_mode (private|public_to) + agent_invocation_target
table (workspace/member/team targets) + lossless backfill from visibility
(migration 130).
- canInvokeAgent: owner-only for private (NO admin bypass, NO A2A bypass);
public_to honours the allow-list; A2A judged by the top-of-chain originator.
- All trigger paths rewired: issue assign, comment @agent/@squad, chat,
quick-create, autopilot, squad leader, child-done.
- Agent API: permission_mode + invocation_targets on responses and
create/update (owner-only writes); legacy visibility kept as a derived field
so old clients never see a permission widening.
- Composio: BuildTaskOverlay now FOLLOWS invocation permission and uses the
agent OWNER connection (removed the originator==owner gate); front-end warns
when a shared agent enables Composio apps.
- CLI: --permission-mode / --public-to-workspace / --public-to-member (legacy
--visibility still mapped).
- Frontend: AccessPicker (Private / workspace / specific people / team soon),
permission rules mirror canInvokeAgent, Composio warning banner.
- Tests: migration backfill, admin cannot invoke others private, public_to
workspace/member whitelist, A2A by originator, Composio overlay uses owner
connection.
Co-authored-by: multica-agent <github@multica.ai>
* feat(agents): stackable, mixed public_to invocation targets (MUL-3963)
Follow-up on PR #4844: public_to now supports selecting MULTIPLE, MIXED
targets on one agent (e.g. Public to workspace + specific people + team),
with canInvokeAgent admitting on ANY matching target (OR).
- Frontend AccessPicker: reworked from a single exclusive kind into a
stackable multi-select — an "Everyone in workspace" toggle, a member
multi-select checklist, and a (disabled, v1) team placeholder can be
combined freely. Emits the full union of selected targets; empty union
collapses to Private. Existing team targets are preserved across saves.
Added the access.public_group locale string (en/zh-Hans/ja/ko).
- Backend already supported this (agent_invocation_target is multi-row per
agent; create/update take a target ARRAY and batch-replace the whole
allow-list; canInvokeAgent OR-matches). Added tests to lock it in:
mixed member+team targets, overlapping-member batch replace, and
workspace+member stacking then narrowing.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): address review on invocation permission (MUL-3963)
张大彪 review on PR #4844 — three blockers + product ruling + nits:
1. Migration 130: drop the FK/cascade on agent_invocation_target
(agent_id, created_by) per the Multica no-FK rule; relationships are now
maintained in the app layer (matching MUL-3515 §4). Added
DeleteAgentInvocationTargetsByArchivedRuntimeAgents and call it before
DeleteArchivedAgentsByRuntime in all three runtime-delete paths
(runtime.go x2, runtime_profile.go) so hard-deleting agents can't orphan
target rows.
2. revokeAndRemoveMember: prune the leaving member's member-target grants
(DeleteAgentInvocationTargetsByMember) in the same tx as the member-row
delete, so a re-invited user can't reclaim a stale invocation grant.
3. Empty public_to is a phantom — parsePermissionInput now normalises a
public_to with no resolvable targets to a single workspace target, so
`--permission-mode public_to` alone (and any empty target array) means
"public to workspace" instead of "shared but nobody can run it".
Product ruling: the system/no-human-originator → workspace-target path in
canInvokeAgent is a deliberate, documented exception (webhook/system/
workspace-wide automation); member/team targets still fail closed without a
resolved originator. Documented in code + locked with a test.
Nits: refreshed the stale "originator must be owner" comments — models.go
(via migration 130 COMMENT ON COLUMN + sqlc regen for composio_toolkit_allowlist
and originator_user_id) and agent-mcp-tab.tsx — to the owner-connection +
invocation-permission rules.
Tests: member remove/re-add regression, system workspace exception + member
fail-closed, empty public_to → workspace (plus the earlier mixed/overlap/
batch-replace suite). Migration 130 applied to the test DB; Go handler/service/
composio suites green; views typecheck clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): scope member invocation-target cleanup to one workspace (MUL-3963)
张大彪 3rd review — cross-workspace permission bug + comment nits:
- DeleteAgentInvocationTargetsByMember was a GLOBAL delete by user id, so
removing a user from workspace A also wiped their member-target grants on
agents in workspace B. Scoped it to a single workspace by joining through
agent.workspace_id; revokeAndRemoveMember now passes (workspaceID, userID).
- Regression test TestRevokeMember_InvocationTargetCleanupIsWorkspaceScoped:
same user allow-listed by agents in two workspaces; removal from one leaves
the other workspace's target intact.
- Nits: refreshed the remaining stale "originator == agent.owner_id" /
"owner-vs-originator" comments — CreateRetryTask (agent.sql, regenerated),
and the AgentResponse allowlist doc + ListAgents/UpdateAgent redaction
rationale in agent.go — to the owner-connection + invocation-permission rule.
Migration 130 applied to the test DB; Go handler/service/composio suites green;
go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): agent access owner-only editable, read-only for others (MUL-3963) (#4853)
* fix(agents): make agent access owner-only editable, read-only for others (MUL-3963)
Interaction bug: a non-owner (incl. workspace admin) could open the AccessPicker
and set an agent public — the backend silently ignored it and the UI bounced
back to private. Access is owner-only, so non-owners must see a read-only state
and the backend must reject real changes explicitly.
Frontend:
- AccessPicker renders a static, non-interactive read-only state when the
viewer is not the owner: the current access value + a lock affordance + a
tooltip "Only the agent owner can change who can run this agent." No clickable
trigger is rendered, so a non-owner can never open a control the backend would
reject (the GitHub/Notion pattern for permission settings you can see but not
edit). The editable multi-select picker is unchanged for the owner.
- agent-detail-inspector gates the picker on ownership specifically
(currentUserId === agent.owner_id), NOT the general canEdit (which also admits
admins, who may edit other fields but not access).
- New locale key access.owner_only_readonly (en/zh-Hans/ja/ko).
Backend:
- UpdateAgent now returns an explicit 403 when a non-owner submits a REAL
permission change (permissionInputChangesAgent compares requested mode +
target set against the persisted state); a no-op resubmit (admin PATCH-as-PUT
echoing unchanged permission) is still tolerated so admin edits of other
fields keep working. Replaces the previous silent-drop that caused the bounce.
Tests:
- access-picker.test.tsx: non-owner gets a non-interactive read-only display
with the owner-only tooltip; owner gets an interactive picker; owner can pick
a member and stack workspace + member.
- TestUpdateAgent_AccessChangeIsOwnerOnly: admin real change → 403; admin no-op
resubmit → 200; admin editing other fields → 200; owner change → 200.
Incidental: fixed a pre-existing base typecheck break in
slash-command-suggestion.test.tsx (stray `signal` arg not in the suggestion
items type) that otherwise fails the whole @multica/views typecheck.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
* fix(agents): compare legacy visibility, not expanded permission, for no-op detection (MUL-3963)
PR #4853 review: permissionInputChangesAgent expanded a legacy-only
visibility:"private" into a real private permission and compared it against the
agent's actual permission. A member-only public_to agent derives legacy
visibility "private", so an admin PATCH-as-PUT echoing visibility:"private"
while editing another field was misread as a public_to→private downgrade and
rejected with 403 — contradicting the "unchanged permission no-op is allowed"
contract.
Fix (per review): when a request carries ONLY legacy `visibility` (no
permission_mode / invocation_targets), derive the agent's CURRENT legacy
visibility from its real targets and compare the legacy string values. Equal =
no-op (allowed); a real legacy change (e.g. "workspace") still returns 403.
Requests that carry permission_mode / invocation_targets keep the precise
mode+target comparison.
Regression test TestUpdateAgent_LegacyVisibilityNoOpForMemberOnlyPublicTo:
member-only public_to agent — admin submitting visibility:"private" + a
non-permission field → 200 with targets unchanged; admin submitting
visibility:"workspace" → 403.
Go handler/composio suites green; migration 130 applied; go vet clean.
Refs MUL-3963.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
* feat(composio): brief agents on connected apps
* feat(composio): gate MCP apps behind feature flag
* fix(mobile): parse agent invocation permissions
* fix(tests): update agent fixtures for access fields
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Multica Eve <eve@devv.ai>
Co-authored-by: Eve <eve@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
4021 lines
140 KiB
Go
4021 lines
140 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"
|
|
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
|
|
|
|
const (
|
|
handlerTestEmail = "handler-test@multica.ai"
|
|
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})
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
|
|
ctx := context.Background()
|
|
var previous string
|
|
if err := testPool.QueryRow(ctx, `SELECT issue_prefix FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&previous); err != nil {
|
|
t.Fatalf("load workspace prefix: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, prefix, testWorkspaceID); err != nil {
|
|
t.Fatalf("set workspace prefix: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT id FROM agent_runtime WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("failed to load handler test runtime: %v", err)
|
|
}
|
|
|
|
return runtimeID
|
|
}
|
|
|
|
func createHandlerTestAgent(t *testing.T, name string, mcpConfig []byte) string {
|
|
t.Helper()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id,
|
|
instructions, custom_env, custom_args, mcp_config
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 'public_to', 1, $4, '', '{}'::jsonb, '[]'::jsonb, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, name, handlerTestRuntimeID(t), testUserID, mcpConfig).Scan(&agentID); err != nil {
|
|
t.Fatalf("failed to create handler test agent: %v", err)
|
|
}
|
|
// 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.
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
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); err != nil {
|
|
t.Fatalf("failed to seed workspace invocation target: %v", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, issue_id, started_at)
|
|
VALUES ($1, $2, 'running', 0, $3, now())
|
|
RETURNING id
|
|
`, agentID, handlerTestRuntimeID(t), issueArg).Scan(&taskID); err != nil {
|
|
t.Fatalf("failed to create handler test task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
func fetchAgentMcpConfig(t *testing.T, agentID string) []byte {
|
|
t.Helper()
|
|
|
|
var mcpConfig []byte
|
|
if err := testPool.QueryRow(context.Background(), `SELECT mcp_config FROM agent WHERE id = $1`, agentID).Scan(&mcpConfig); err != nil {
|
|
t.Fatalf("failed to load agent mcp_config: %v", err)
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue to delete by identifier",
|
|
"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.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.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+created.Identifier, nil)
|
|
req = withURLParam(req, "id", created.Identifier)
|
|
testHandler.DeleteIssue(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteIssue by identifier: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM issue WHERE id = $1`, created.ID,
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count query: %v", err)
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/issues/not-a-uuid-or-identifier", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid-or-identifier")
|
|
testHandler.DeleteIssue(w, 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue with no explicit status",
|
|
})
|
|
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.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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Explicit backlog issue",
|
|
"status": "backlog",
|
|
})
|
|
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.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) {
|
|
ctx := context.Background()
|
|
|
|
var otherWorkspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Cross-workspace parent test", "xwp-parent-test", "Foreign workspace", "XWP").Scan(&otherWorkspaceID); err != nil {
|
|
t.Fatalf("insert foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, otherWorkspaceID)
|
|
})
|
|
|
|
var foreignParentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_type, creator_id, number)
|
|
VALUES ($1, $2, 'todo', 'none', 'member', $3, 1)
|
|
RETURNING id
|
|
`, otherWorkspaceID, "Foreign parent", testUserID).Scan(&foreignParentID); err != nil {
|
|
t.Fatalf("insert foreign parent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Should be rejected",
|
|
"parent_issue_id": foreignParentID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue with foreign parent: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
|
|
testWorkspaceID, "Should be rejected",
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count query: %v", err)
|
|
}
|
|
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) {
|
|
ctx := context.Background()
|
|
|
|
var otherWorkspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Cross-workspace project test", "xwp-project-test", "Foreign workspace", "XWP").Scan(&otherWorkspaceID); err != nil {
|
|
t.Fatalf("insert foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, otherWorkspaceID)
|
|
})
|
|
|
|
var foreignProjectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title, status, priority)
|
|
VALUES ($1, $2, 'planned', 'none')
|
|
RETURNING id
|
|
`, otherWorkspaceID, "Foreign project").Scan(&foreignProjectID); err != nil {
|
|
t.Fatalf("insert foreign project: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Should be rejected",
|
|
"project_id": foreignProjectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue with foreign project: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
|
|
testWorkspaceID, "Should be rejected",
|
|
).Scan(&count); err != nil {
|
|
t.Fatalf("count query: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Sub-issue inheritance project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var project ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&project)
|
|
projectID = project.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent with project",
|
|
"project_id": projectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child without explicit project",
|
|
"parent_issue_id": parentID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parentProject ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&parentProject)
|
|
parentProjectID = parentProject.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child explicit project",
|
|
})
|
|
testHandler.CreateProject(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateProject child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var childProject ProjectResponse
|
|
json.NewDecoder(w.Body).Decode(&childProject)
|
|
childProjectID = childProject.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Parent with project",
|
|
"project_id": parentProjectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Child with explicit project",
|
|
"parent_issue_id": parentID,
|
|
"project_id": childProjectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue child: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, testWorkspaceID, "Duplicate guard project "+suffix).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project fixture: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Duplicate guard parent " + suffix,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue parent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var parent IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&parent); err != nil {
|
|
t.Fatalf("decode parent: %v", err)
|
|
}
|
|
parentID = parent.ID
|
|
|
|
title := "SH-PM-SYNTH-01 Synthesize recommendation-to-shortlist planning outputs " + suffix
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "in_progress",
|
|
"parent_issue_id": parentID,
|
|
"project_id": projectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue original: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var original IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&original); err != nil {
|
|
t.Fatalf("decode original: %v", err)
|
|
}
|
|
issueID = original.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
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,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateIssue duplicate: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var conflict struct {
|
|
Code string `json:"code"`
|
|
Error string `json:"error"`
|
|
Issue IssueResponse `json:"issue"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&conflict); err != nil {
|
|
t.Fatalf("decode conflict: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"parent_issue_id": parentID,
|
|
"project_id": projectID,
|
|
"allow_duplicate": true,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue allow duplicate: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var duplicate IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&duplicate); err != nil {
|
|
t.Fatalf("decode duplicate: %v", err)
|
|
}
|
|
duplicateID = duplicate.ID
|
|
if duplicateID == issueID {
|
|
t.Fatalf("allow duplicate returned original issue id %s", duplicateID)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"parent_issue_id": parentID,
|
|
"project_id": projectID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateIssue duplicate after allow-duplicate: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&conflict); err != nil {
|
|
t.Fatalf("decode second conflict: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "cancelled",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue cancelled: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var first IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&first); err != nil {
|
|
t.Fatalf("decode cancelled: %v", err)
|
|
}
|
|
firstID = first.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue after cancelled: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var second IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&second); err != nil {
|
|
t.Fatalf("decode second: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "done",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue done: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var first IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&first); err != nil {
|
|
t.Fatalf("decode done: %v", err)
|
|
}
|
|
firstID = first.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue after done: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var second IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&second); err != nil {
|
|
t.Fatalf("decode second: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "todo",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue existing: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var existing IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&existing); err != nil {
|
|
t.Fatalf("decode existing issue: %v", err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
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,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = autopilot.ID
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/autopilots/"+autopilotID+"/trigger?workspace_id="+testWorkspaceID, nil)
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.TriggerAutopilot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("TriggerAutopilot duplicate title: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var run AutopilotRunResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&run); err != nil {
|
|
t.Fatalf("decode autopilot run: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count); err != nil {
|
|
t.Fatalf("count issues: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": "todo",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue existing: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var existing IssueResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&existing); err != nil {
|
|
t.Fatalf("decode existing issue: %v", err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
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,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
autopilotID = autopilot.ID
|
|
|
|
queries := db.New(testPool)
|
|
ap, err := queries.GetAutopilot(ctx, parseUUID(autopilotID))
|
|
if err != nil {
|
|
t.Fatalf("GetAutopilot: %v", err)
|
|
}
|
|
run, err := testHandler.AutopilotService.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "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
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue WHERE workspace_id = $1 AND title = $2`, testWorkspaceID, title).Scan(&count); err != nil {
|
|
t.Fatalf("count issues: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
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,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
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.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
|
|
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
|
|
if err := testPool.QueryRow(ctx, `
|
|
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); err != nil {
|
|
t.Fatalf("load autopilot-created issue: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id::text
|
|
`, testWorkspaceID, "Autopilot project target").Scan(&projectID); err != nil {
|
|
t.Fatalf("create project fixture: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
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,
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var autopilot AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&autopilot); err != nil {
|
|
t.Fatalf("decode autopilot: %v", err)
|
|
}
|
|
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.DispatchAutopilot(ctx, ap, pgtype.UUID{}, "manual", nil)
|
|
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
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT project_id::text
|
|
FROM issue
|
|
WHERE id = $1
|
|
`, issueID).Scan(&issueProjectID); err != nil {
|
|
t.Fatalf("load created issue project: %v", err)
|
|
}
|
|
if issueProjectID == nil || *issueProjectID != projectID {
|
|
t.Fatalf("created issue project_id = %v, want %q", issueProjectID, projectID)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}()
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title)
|
|
VALUES ($1, $2)
|
|
RETURNING id::text
|
|
`, testWorkspaceID, "Autopilot update project target").Scan(&projectID); err != nil {
|
|
t.Fatalf("create project fixture: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("load test agent: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Project update autopilot",
|
|
"assignee_id": agentID,
|
|
"execution_mode": "create_issue",
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAutopilot: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var created AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created autopilot: %v", err)
|
|
}
|
|
autopilotID = created.ID
|
|
if created.ProjectID != nil {
|
|
t.Fatalf("new autopilot project_id = %v, want nil", created.ProjectID)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"project_id": projectID,
|
|
})
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAutopilot set project: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var updated AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode updated autopilot: %v", err)
|
|
}
|
|
if updated.ProjectID == nil || *updated.ProjectID != projectID {
|
|
t.Fatalf("updated project_id = %v, want %q", updated.ProjectID, projectID)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PATCH", "/api/autopilots/"+autopilotID+"?workspace_id="+testWorkspaceID, map[string]any{
|
|
"project_id": nil,
|
|
})
|
|
req = withURLParam(req, "id", autopilotID)
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAutopilot clear project: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var cleared AutopilotResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&cleared); err != nil {
|
|
t.Fatalf("decode cleared autopilot: %v", err)
|
|
}
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
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",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for nonexistent member, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
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",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for nonexistent agent, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Lone assignee_type",
|
|
"assignee_type": "member",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 when only assignee_type is set, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsAssigneeIDWithoutType is the symmetric case.
|
|
func TestCreateIssueRejectsAssigneeIDWithoutType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Lone assignee_id",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 when only assignee_id is set, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueRejectsUnknownAssigneeType guards against typos like
|
|
// "members" or "user" that previously sneaked through.
|
|
func TestCreateIssueRejectsUnknownAssigneeType(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Bogus assignee_type",
|
|
"assignee_type": "user",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for unknown assignee_type, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateIssueAcceptsValidMemberAssignee is the positive control — the
|
|
// validator must not block legitimate workspace members.
|
|
func TestCreateIssueAcceptsValidMemberAssignee(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Valid member assignee",
|
|
"assignee_type": "member",
|
|
"assignee_id": testUserID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue: expected 201 for valid member assignee, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Malformed assignee_id only",
|
|
"assignee_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateIssueRejectsMalformedAttachmentIDBeforeWrite(t *testing.T) {
|
|
var before int
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&before); err != nil {
|
|
t.Fatalf("count issues before: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Malformed attachment issue",
|
|
"attachment_ids": []string{"not-a-uuid"},
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateIssue: expected 400 for malformed attachment_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var after int
|
|
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM issue WHERE workspace_id = $1`, testWorkspaceID).Scan(&after); err != nil {
|
|
t.Fatalf("count issues after: %v", err)
|
|
}
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Update malformed assignee target",
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"assignee_id": "not-a-uuid",
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateIssue: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestUpdateIssueRejectsNonexistentMemberAssignee verifies the same gap is
|
|
// closed on the update path — UpdateIssue previously only validated agents.
|
|
func TestUpdateIssueRejectsNonexistentMemberAssignee(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Update assignee target",
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateIssue: expected 400 for nonexistent member, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Issue to unassign",
|
|
"assignee_type": "member",
|
|
"assignee_id": testUserID,
|
|
})
|
|
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)
|
|
defer func() {
|
|
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
|
|
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
|
|
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
|
|
}()
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"assignee_type": nil,
|
|
"assignee_id": nil,
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200 for unassign, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Comment test issue",
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
issueID := issue.ID
|
|
|
|
// Create comment
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": "Test comment from Go test",
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// List comments
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/issues/"+issueID+"/comments", nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.ListComments(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListComments: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+issueID, nil)
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
func TestCommentWritePathsPreserveIssueIdentifiers(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("requires DB")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
setWorkspaceIssuePrefixForTest(t, "MUL")
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, number)
|
|
VALUES ($1, 'member', $2, $3, 3310)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, "preserve bare issue identifiers").Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue fixture: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
})
|
|
|
|
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 {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": content,
|
|
})
|
|
req = withURLParam(req, "id", issueID)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment(%q): expected 201, got %d: %s", content, w.Code, w.Body.String())
|
|
}
|
|
|
|
var created CommentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode created comment: %v", err)
|
|
}
|
|
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
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/comments/"+firstCommentID, map[string]any{
|
|
"content": updatedContent,
|
|
})
|
|
req = withURLParam(req, "commentId", firstCommentID)
|
|
testHandler.UpdateComment(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateComment: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated CommentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode updated comment: %v", err)
|
|
}
|
|
if updated.Content != updatedContent {
|
|
t.Fatalf("UpdateComment stored %q, want %q", updated.Content, updatedContent)
|
|
}
|
|
}
|
|
|
|
func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Comment malformed parent issue",
|
|
})
|
|
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)
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateComment: expected 400 for malformed parent_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("DELETE", "/api/issues/"+issue.ID, nil)
|
|
req = withURLParam(req, "id", issue.ID)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|
|
|
|
func TestGetChatSessionRejectsMalformedSessionID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/chat/sessions/not-a-uuid", nil)
|
|
req = withURLParam(req, "sessionId", "not-a-uuid")
|
|
testHandler.GetChatSession(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetChatSession: expected 400 for malformed sessionId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAutopilotRejectsMalformedAssigneeID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/autopilots", map[string]any{
|
|
"title": "Malformed assignee autopilot",
|
|
"assignee_id": "not-a-uuid",
|
|
"execution_mode": "run_only",
|
|
})
|
|
testHandler.CreateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAutopilot: expected 400 for malformed assignee_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAutopilotRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/autopilots/not-a-uuid", map[string]any{
|
|
"title": "Malformed autopilot id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateAutopilot(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAutopilot: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAgentRejectsMalformedAgentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/not-a-uuid", map[string]any{
|
|
"name": "Malformed agent id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAgent: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAgentRejectsMalformedRuntimeID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents", map[string]any{
|
|
"name": "Malformed runtime agent",
|
|
"runtime_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateAgentRejectsMalformedRuntimeID(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "Handler Malformed Runtime Update", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"runtime_id": "not-a-uuid",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateAgent: expected 400 for malformed runtime_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreatePinRejectsMalformedItemID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/pins", map[string]any{
|
|
"item_type": "issue",
|
|
"item_id": "not-a-uuid",
|
|
})
|
|
testHandler.CreatePin(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreatePin: expected 400 for malformed item_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateWorkspaceRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/workspaces/not-a-uuid", map[string]any{
|
|
"name": "Malformed workspace id",
|
|
})
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.UpdateWorkspace(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateWorkspace: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateMemberRejectsMalformedMemberID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PATCH", "/api/workspaces/"+testWorkspaceID+"/members/not-a-uuid", map[string]any{
|
|
"role": "member",
|
|
})
|
|
req = withURLParams(req, "id", testWorkspaceID, "memberId", "not-a-uuid")
|
|
testHandler.UpdateMember(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateMember: expected 400 for malformed memberId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeInvitationRejectsMalformedInvitationID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/invitations/not-a-uuid", nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "invitationId", "not-a-uuid")
|
|
testHandler.RevokeInvitation(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("RevokeInvitation: expected 400 for malformed invitationId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMyInvitationRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/invitations/not-a-uuid", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.GetMyInvitation(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetMyInvitation: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAddReactionRejectsMalformedCommentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/comments/not-a-uuid/reactions", map[string]any{
|
|
"emoji": "thumbs_up",
|
|
})
|
|
req = withURLParam(req, "commentId", "not-a-uuid")
|
|
testHandler.AddReaction(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("AddReaction: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateCommentRejectsMalformedCommentID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/comments/not-a-uuid", map[string]any{
|
|
"content": "updated",
|
|
})
|
|
req = withURLParam(req, "commentId", "not-a-uuid")
|
|
testHandler.UpdateComment(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("UpdateComment: expected 400 for malformed commentId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMarkInboxReadRejectsMalformedItemID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/inbox/not-a-uuid/read", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.MarkInboxRead(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("MarkInboxRead: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokePersonalAccessTokenRejectsMalformedID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/tokens/not-a-uuid", nil)
|
|
req = withURLParam(req, "id", "not-a-uuid")
|
|
testHandler.RevokePersonalAccessToken(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("RevokePersonalAccessToken: expected 400 for malformed id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/deregister", map[string]any{
|
|
"runtime_ids": []string{"not-a-uuid"},
|
|
})
|
|
testHandler.DaemonDeregister(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("DaemonDeregister: expected 400 for malformed runtime_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetIssueGCCheckRejectsMalformedIssueID(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/daemon/issues/not-a-uuid/gc-check", nil)
|
|
req = withURLParam(req, "issueId", "not-a-uuid")
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("GetIssueGCCheck: expected 400 for malformed issueId, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetAgentSkillsRejectsMalformedSkillID(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "Handler Malformed Skill Assignment", nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID+"/skills", map[string]any{
|
|
"skill_ids": []string{"not-a-uuid"},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.SetAgentSkills(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("SetAgentSkills: expected 400 for malformed skill_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
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")
|
|
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
|
|
agentID, existingSkillID,
|
|
); err != nil {
|
|
t.Fatalf("seed existing skill assignment: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
|
|
"skill_ids": []string{newSkillID},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.AddAgentSkills(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("AddAgentSkills: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp []SkillSummaryResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
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++ {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
|
|
"skill_ids": []string{skillA, skillB},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.AddAgentSkills(w, 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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
|
|
"skill_ids": []string{"not-a-uuid"},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.AddAgentSkills(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("AddAgentSkills: expected 400 for malformed skill_ids, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAddAgentSkillsRejectsCrossWorkspaceSkillID(t *testing.T) {
|
|
agentID := createHandlerTestAgent(t, "Handler Add Cross Workspace Skill", nil)
|
|
foreignSkillID := insertHandlerTestSkillInForeignWorkspace(t, "add-cross-workspace", "foreign body")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/agents/"+agentID+"/skills/add", map[string]any{
|
|
"skill_ids": []string{foreignSkillID},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.AddAgentSkills(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("AddAgentSkills: expected 404 for cross-workspace skill_id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
assertAgentSkillRowCount(t, agentID, 0)
|
|
}
|
|
|
|
func insertHandlerTestSkillInForeignWorkspace(t *testing.T, namePrefix, content string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
slug := "foreign-skill-" + strings.ToLower(strings.ReplaceAll(t.Name(), "_", "-"))
|
|
|
|
var workspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Foreign Skill Workspace "+t.Name(), slug, "", "FSW").Scan(&workspaceID); err != nil {
|
|
t.Fatalf("insert foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, workspaceID)
|
|
})
|
|
|
|
name := namePrefix + "-" + t.Name()
|
|
var skillID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
|
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5)
|
|
RETURNING id
|
|
`, workspaceID, name, "fixture", content, testUserID).Scan(&skillID); err != nil {
|
|
t.Fatalf("insert foreign skill: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID)
|
|
})
|
|
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
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM agent_skill WHERE agent_id = $1`,
|
|
agentID,
|
|
).Scan(&got); err != nil {
|
|
t.Fatalf("count agent_skill: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("agent_skill row count: got %d, want %d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAgentCRUD(t *testing.T) {
|
|
// List agents
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
|
|
testHandler.ListAgents(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListAgents: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"status": "idle",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"name": "Handler Mcp Preserve Updated",
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"mcp_config": nil,
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
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"}`))
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/agents/"+agentID, map[string]any{
|
|
"mcp_config": map[string]any{"preset": "new"},
|
|
})
|
|
req = withURLParam(req, "id", agentID)
|
|
testHandler.UpdateAgent(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateAgent: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var updated AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("UpdateAgent: decode response: %v", err)
|
|
}
|
|
assertJSONEqual(t, updated.McpConfig, `{"preset":"new"}`)
|
|
assertJSONEqual(t, fetchAgentMcpConfig(t, agentID), `{"preset":"new"}`)
|
|
}
|
|
|
|
func TestCreateAgentMcpConfigNullStoresSQLNull(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
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{},
|
|
})
|
|
testHandler.CreateAgent(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateAgent: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created AgentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("CreateAgent: decode response: %v", err)
|
|
}
|
|
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 := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/workspaces", nil)
|
|
testHandler.ListWorkspaces(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ListWorkspaces: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
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
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("GET", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.GetWorkspace(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetWorkspace: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Handler Create Workspace Requested",
|
|
"slug": slug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateWorkspace: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var created WorkspaceResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
|
|
t.Fatalf("CreateWorkspace: decode response: %v", err)
|
|
}
|
|
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)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Duplicate Handler Workspace",
|
|
"slug": handlerTestWorkspaceSlug,
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("CreateWorkspace: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM workspace WHERE slug = $1`, retriedSlug).Scan(&count); err != nil {
|
|
t.Fatalf("CreateWorkspace: check retried slug: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("CreateWorkspace: expected no fallback slug %q, got %d rows", retriedSlug, count)
|
|
}
|
|
}
|
|
|
|
func TestCreateWorkspaceInvalidSlugReturnsBadRequest(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces", map[string]string{
|
|
"name": "Invalid Slug Workspace",
|
|
"slug": "invalid slug",
|
|
})
|
|
testHandler.CreateWorkspace(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("CreateWorkspace: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSendCode(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
body := map[string]string{"email": "sendcode-test@multica.ai"}
|
|
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`, "sendcode-test@multica.ai")
|
|
})
|
|
}
|
|
|
|
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": "dberror-test@multica.ai"}
|
|
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 = "ratelimit-test@multica.ai"
|
|
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 = "verify-test@multica.ai"
|
|
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 = "dev-code-disabled-test@multica.ai"
|
|
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 = "dev-code-enabled-test@multica.ai"
|
|
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 = "dev-code-production-test@multica.ai"
|
|
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 = "wrong-code-test@multica.ai"
|
|
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 = "bruteforce-test@multica.ai"
|
|
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 = "workspace-verify-test@multica.ai"
|
|
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)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Backlog no-trigger test",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
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)
|
|
|
|
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.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Backlog trigger test",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
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)
|
|
|
|
// Move the issue from backlog to todo — should trigger.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
|
|
"status": "todo",
|
|
})
|
|
req = withURLParam(req, "id", created.ID)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 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).
|
|
w := httptest.NewRecorder()
|
|
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,
|
|
})
|
|
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)
|
|
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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var childTasks int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
created.ID, childAgent,
|
|
).Scan(&childTasks); err != nil {
|
|
t.Fatalf("failed to count child tasks: %v", err)
|
|
}
|
|
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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Self-promoted backlog",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": selfAgent,
|
|
})
|
|
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)
|
|
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)
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var tasks int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
created.ID, selfAgent,
|
|
).Scan(&tasks); err != nil {
|
|
t.Fatalf("failed to count tasks: %v", err)
|
|
}
|
|
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.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Step 1 (running)",
|
|
"status": "in_progress",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue step1: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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.
|
|
w = httptest.NewRecorder()
|
|
req = newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Step 2 (backlog)",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": agentID,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue step2: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue step2: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var step2Tasks int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
step2.ID, agentID,
|
|
).Scan(&step2Tasks); err != nil {
|
|
t.Fatalf("failed to count step2 tasks: %v", err)
|
|
}
|
|
if step2Tasks != 1 {
|
|
t.Fatalf("expected exactly 1 task enqueued on step2 for same-agent serial chain, got %d", step2Tasks)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Batch backlog child",
|
|
"status": "backlog",
|
|
"assignee_type": "agent",
|
|
"assignee_id": childAgent,
|
|
})
|
|
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)
|
|
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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.BatchUpdateIssues(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("BatchUpdateIssues: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var childTasks int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
created.ID, childAgent,
|
|
).Scan(&childTasks); err != nil {
|
|
t.Fatalf("failed to count child tasks: %v", err)
|
|
}
|
|
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)
|
|
|
|
var squadID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO squad (workspace_id, name, description, leader_id, creator_id)
|
|
VALUES ($1, $2, '', $3, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, "Backlog Trigger Squad", leaderAgent, testUserID).Scan(&squadID); err != nil {
|
|
t.Fatalf("create squad: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM squad WHERE id = $1`, squadID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Squad backlog issue",
|
|
"status": "backlog",
|
|
"assignee_type": "squad",
|
|
"assignee_id": squadID,
|
|
})
|
|
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)
|
|
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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.UpdateIssue(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var leaderTasks int
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
|
|
created.ID, leaderAgent,
|
|
).Scan(&leaderTasks); err != nil {
|
|
t.Fatalf("failed to count leader tasks: %v", err)
|
|
}
|
|
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) {
|
|
w := httptest.NewRecorder()
|
|
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)
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonRegister: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "PR review delegation no-leak 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)
|
|
})
|
|
|
|
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)
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("J PR completion: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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.
|
|
if _, err := testPool.Exec(ctx,
|
|
`UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1 AND agent_id = $2`,
|
|
issueID, reviewerAgent,
|
|
); err != nil {
|
|
t.Fatalf("cancel reviewer task: %v", err)
|
|
}
|
|
|
|
// 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.
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("member follow-up: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
assigneeAgent := createHandlerTestAgent(t, "Nested Mention Assignee", nil)
|
|
mentionedAgent := createHandlerTestAgent(t, "Nested Mention Target", nil)
|
|
parentAgent := createHandlerTestAgent(t, "Nested Direct Parent", nil)
|
|
|
|
var number int
|
|
if err := testPool.QueryRow(ctx, `
|
|
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); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id, number)
|
|
VALUES ($1, 'member', $2, $3, 'agent', $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, "nested mention inheritance regression", assigneeAgent, number).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
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
|
|
if 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); err != nil {
|
|
t.Fatalf("count queued tasks: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
postMemberComment := func(body map[string]any) CommentResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp CommentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode comment response: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent_task_queue SET status = 'cancelled' WHERE issue_id = $1`, issueID); err != nil {
|
|
t.Fatalf("cancel root mention task: %v", err)
|
|
}
|
|
|
|
var directParentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, parent_id)
|
|
VALUES ($1, $2, 'agent', $3, $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, issueID, parentAgent, "looks like redirect config", root.ID).Scan(&directParentID); err != nil {
|
|
t.Fatalf("insert direct parent reply: %v", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestNestedMemberReplyWithMemberParentFallsBackToAssignee verifies that a
|
|
// nested reply whose direct parent is human-owned does not route to a sibling
|
|
// agent reply. It falls through to the issue assignee instead.
|
|
func TestNestedMemberReplyUsesDirectParentForAssigneeParticipation(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
assigneeAgent := createHandlerTestAgent(t, "Nested Participation Assignee", nil)
|
|
|
|
var number int
|
|
if err := testPool.QueryRow(ctx, `
|
|
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); err != nil {
|
|
t.Fatalf("next issue number: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, creator_type, creator_id, title, assignee_type, assignee_id, number)
|
|
VALUES ($1, 'member', $2, $3, 'agent', $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID, "nested participation regression", assigneeAgent, number).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
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
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_task_queue
|
|
WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'
|
|
`, issueID, assigneeAgent).Scan(&n); err != nil {
|
|
t.Fatalf("count queued tasks: %v", err)
|
|
}
|
|
return n
|
|
}
|
|
postMemberComment := func(body map[string]any) CommentResponse {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
r := newRequest("POST", "/api/issues/"+issueID+"/comments", body)
|
|
r = withURLParam(r, "id", issueID)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateComment: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp CommentResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode comment response: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
var rootID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content)
|
|
VALUES ($1, $2, 'member', $3, 'this cache question is for humans')
|
|
RETURNING id
|
|
`, testWorkspaceID, issueID, testUserID).Scan(&rootID); err != nil {
|
|
t.Fatalf("insert root comment: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
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); err != nil {
|
|
t.Fatalf("insert assignee reply: %v", err)
|
|
}
|
|
var humanParentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (workspace_id, issue_id, author_type, author_id, content, parent_id)
|
|
VALUES ($1, $2, 'member', $3, 'I have seen this too', $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, issueID, testUserID, rootID).Scan(&humanParentID); err != nil {
|
|
t.Fatalf("insert human direct parent: %v", err)
|
|
}
|
|
|
|
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 != 1 {
|
|
t.Fatalf("plain nested human reply should fall back to assignee; got %d queued tasks", 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)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": "Agent explicit handoff 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)
|
|
})
|
|
|
|
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)
|
|
w = httptest.NewRecorder()
|
|
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)
|
|
testHandler.CreateComment(w, r)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("agent A handoff: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
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())
|
|
}
|
|
}
|