Files
multica/server/internal/handler/agent_template_permission_test.go
Multica Eve d90ee9fa35 fix(agents): thread permission_mode/invocation_targets through the template create path (MUL-4010) (#4897)
CreateAgentFromTemplate accepted only the legacy visibility field and dropped
it on the floor: neither permission_mode nor invocation_targets flowed into
the INSERT, so the SQL default (COALESCE(sqlc.narg('permission_mode'),
'private')) pinned every template-created agent as private in the new
invocation-permission model (MUL-3963). Since canInvokeAgent reads
permission_mode — not the legacy visibility column — a request that asked
for a workspace-shared agent (old Web/CLI/Desktop sending
visibility="workspace", or new Web sending permission_mode/public_to +
invocation_targets) silently landed as owner-only. The public_to+targets
inputs from the new Web front-end were also being ignored.

Fix (mirrors handler/agent.go:CreateAgent so the two entry points can't
drift):

- CreateAgentFromTemplateRequest gains PermissionMode *string and
  InvocationTargets []AgentInvocationTargetDTO.
- Decode via decodeJSONBodyWithRawFields to distinguish an absent
  invocation_targets from an empty one (same rawFields lookup CreateAgent
  uses).
- Call parsePermissionInput(wsUUID, req.PermissionMode,
  req.InvocationTargets, req.PermissionMode != nil, hasTargets,
  &legacyVis) so the legacy 'workspace' mapping ('workspace' -> public_to +
  workspace target) is applied uniformly.
- Pass perm.legacyVisibility() into Visibility and perm.mode into
  PermissionMode on CreateAgentParams so the visibility mirror column stays
  aligned and the permission_mode column reflects the caller's intent
  rather than the SQL default.
- Persist the invocation allow-list inside the same tx as the agent row via
  a new tx-friendly helper replaceInvocationTargetsWithQueries — an agent
  is never observable in a state where the row exists but its targets are
  missing. handler-level replaceInvocationTargets delegates to it with
  h.Queries, keeping the CreateAgent/UpdateAgent call sites unchanged.
- Enrich the response with invocation targets after commit so a client that
  just asked for visibility='workspace' sees the derived legacy visibility
  round-trip correctly (previously the response echoed empty
  invocation_targets and legacy 'private' regardless of intent).

Regression coverage in agent_template_permission_test.go:

- TestCreateAgentFromTemplate_LegacyVisibilityMapsToPermission: both
  legacy visibility values are exercised. workspace -> permission_mode
  public_to + a workspace invocation-target row (row-level SELECTs assert
  the persistence, not just the response echo); private -> permission_mode
  private + zero target rows.
- TestCreateAgentFromTemplate_PublicToWithMemberTarget: new-shape request
  (permission_mode='public_to' + a member invocation-target) is honoured
  verbatim, derived legacy visibility collapses to 'private' (member-only
  public_to), and the DB row for the member target exists.

Uses commit-message as the fixture template (zero external skills), so the
tests don't need to reach any network fetcher.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-03 17:56:02 +08:00

175 lines
6.8 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestCreateAgentFromTemplate_LegacyVisibilityMapsToPermission is the
// MUL-4010 regression: the template create path used to persist
// permission_mode='private' (the SQL default) regardless of the incoming
// legacy `visibility="workspace"` value, so an agent that the caller asked
// to be workspace-shared silently became owner-only in canInvokeAgent.
//
// After the fix, parsePermissionInput runs on the template path too:
// - visibility "workspace" -> permission_mode public_to + workspace target
// - visibility "private" -> permission_mode private + no targets
func TestCreateAgentFromTemplate_LegacyVisibilityMapsToPermission(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
runtimeID := handlerTestRuntimeID(t)
// commit-message ships with zero external skills so this test never
// touches the network fetch path. Any zero-skill template would do.
const templateSlug = "commit-message"
if _, ok := agentTemplates.Get(templateSlug); !ok {
t.Fatalf("expected template %q to be loaded", templateSlug)
}
create := func(name, visibility string) CreateAgentFromTemplateResponse {
w := httptest.NewRecorder()
testHandler.CreateAgentFromTemplate(w, newRequest("POST", "/api/agents/from-template?workspace_id="+testWorkspaceID, map[string]any{
"template_slug": templateSlug,
"name": name,
"runtime_id": runtimeID,
"visibility": visibility,
}))
if w.Code != http.StatusCreated {
t.Fatalf("create %q (visibility=%s): expected 201, got %d: %s", name, visibility, w.Code, w.Body.String())
}
var resp CreateAgentFromTemplateResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, resp.Agent.ID)
})
return resp
}
ws := create("template-legacy-workspace", "workspace")
if ws.Agent.PermissionMode != "public_to" {
t.Errorf("workspace-template agent permission_mode = %q, want public_to", ws.Agent.PermissionMode)
}
if ws.Agent.Visibility != "workspace" {
t.Errorf("workspace-template agent derived visibility = %q, want workspace", ws.Agent.Visibility)
}
foundWorkspaceTarget := false
for _, tgt := range ws.Agent.InvocationTargets {
if tgt.TargetType == "workspace" {
foundWorkspaceTarget = true
}
}
if !foundWorkspaceTarget {
t.Errorf("workspace-template agent invocation_targets = %+v, want a workspace target", ws.Agent.InvocationTargets)
}
// Additional DB-level check: canInvokeAgent's persisted inputs — the row's
// permission_mode column AND at least one invocation-target row — must
// both be present. A response that reflected the intent but a row that
// didn't would still deny non-owner invokes at runtime.
var mode string
if err := testPool.QueryRow(context.Background(),
`SELECT permission_mode FROM agent WHERE id = $1`, ws.Agent.ID).Scan(&mode); err != nil {
t.Fatalf("load persisted permission_mode: %v", err)
}
if mode != "public_to" {
t.Errorf("persisted permission_mode = %q, want public_to", mode)
}
var targetCount int
if err := testPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM agent_invocation_target WHERE agent_id = $1 AND target_type = 'workspace'`,
ws.Agent.ID,
).Scan(&targetCount); err != nil {
t.Fatalf("load persisted workspace targets: %v", err)
}
if targetCount != 1 {
t.Errorf("persisted workspace target rows = %d, want 1", targetCount)
}
priv := create("template-legacy-private", "private")
if priv.Agent.PermissionMode != "private" {
t.Errorf("private-template agent permission_mode = %q, want private", priv.Agent.PermissionMode)
}
if priv.Agent.Visibility != "private" {
t.Errorf("private-template agent derived visibility = %q, want private", priv.Agent.Visibility)
}
if len(priv.Agent.InvocationTargets) != 0 {
t.Errorf("private-template agent invocation_targets = %+v, want none", priv.Agent.InvocationTargets)
}
}
// TestCreateAgentFromTemplate_PublicToWithMemberTarget verifies that when
// the new-Web-shape (permission_mode + invocation_targets) arrives on the
// template path, it is honoured — a member allow-list is persisted verbatim
// instead of being silently dropped and replaced with the SQL default.
func TestCreateAgentFromTemplate_PublicToWithMemberTarget(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
runtimeID := handlerTestRuntimeID(t)
const templateSlug = "commit-message"
if _, ok := agentTemplates.Get(templateSlug); !ok {
t.Fatalf("expected template %q to be loaded", templateSlug)
}
// Fresh member to grant invocation access to.
targetUserID := createPermissionTestMember(t, "template-invoke-target@multica.ai")
w := httptest.NewRecorder()
testHandler.CreateAgentFromTemplate(w, newRequest("POST", "/api/agents/from-template?workspace_id="+testWorkspaceID, map[string]any{
"template_slug": templateSlug,
"name": "template-public-to-member",
"runtime_id": runtimeID,
"permission_mode": "public_to",
"invocation_targets": []map[string]any{
{"target_type": "member", "target_id": targetUserID},
},
}))
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp CreateAgentFromTemplateResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, resp.Agent.ID)
})
if resp.Agent.PermissionMode != "public_to" {
t.Errorf("permission_mode = %q, want public_to", resp.Agent.PermissionMode)
}
// public_to limited to a specific member -> derived legacy visibility
// collapses to "private" (only workspace-target public_to derives to
// "workspace"); the real audience is the member allow-list below.
if resp.Agent.Visibility != "private" {
t.Errorf("derived legacy visibility = %q, want private (member-only public_to)", resp.Agent.Visibility)
}
sawMember := false
for _, tgt := range resp.Agent.InvocationTargets {
if tgt.TargetType == "member" && tgt.TargetID != nil && *tgt.TargetID == targetUserID {
sawMember = true
}
}
if !sawMember {
t.Errorf("invocation_targets = %+v, want a member target for %s", resp.Agent.InvocationTargets, targetUserID)
}
// DB-level check: the member row is persisted, not just echoed back.
var persisted int
if err := testPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM agent_invocation_target WHERE agent_id = $1 AND target_type = 'member' AND target_id = $2`,
resp.Agent.ID, targetUserID,
).Scan(&persisted); err != nil {
t.Fatalf("load persisted member target: %v", err)
}
if persisted != 1 {
t.Errorf("persisted member target rows = %d, want 1", persisted)
}
}