mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* feat: share runtimes with selected workspace members Co-authored-by: multica-agent <[email protected]> * refactor(runtimes): one-click private/public and popover member picker Private and Public apply immediately again (restrictions still go through the reviewed revocation dialog). Specific members opens a draft popover with search, Cancel and Save; selected recipients are summarized under the scope control with an Edit entry point. The editor moves to its own runtime-access-editor component. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
209 lines
10 KiB
Go
209 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
)
|
|
|
|
func runtimeSharingPatch(t *testing.T, runtimeID, actorID string, body map[string]any) *testutil.Response {
|
|
t.Helper()
|
|
req := newRequestAs(actorID, http.MethodPatch, "/api/runtimes/"+runtimeID, body)
|
|
return testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", runtimeID))
|
|
}
|
|
|
|
func TestRuntimeSharingAccess(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Fatal("database-backed fixture required")
|
|
}
|
|
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
|
var runtime AgentRuntimeResponse
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID, memberID, ownerID}}).Want(http.StatusOK).JSON(&runtime)
|
|
if runtime.Visibility != "private" || len(runtime.SharedWith) != 1 || runtime.SharedWith[0] != memberID {
|
|
t.Fatalf("unexpected grant: %+v", runtime)
|
|
}
|
|
var visible []AgentRuntimeResponse
|
|
testutil.Call(t, testHandler.ListAgentRuntimes, newRequestAs(memberID, http.MethodGet, "/api/runtimes", nil)).Want(http.StatusOK).JSON(&visible)
|
|
found := false
|
|
for _, rt := range visible {
|
|
found = found || rt.ID == rtID
|
|
}
|
|
if !found {
|
|
t.Fatal("shared runtime missing from recipient list")
|
|
}
|
|
for _, tc := range []struct {
|
|
actor string
|
|
status int
|
|
}{{ownerID, 200}, {memberID, 200}, {testUserID, 404}} {
|
|
req := newRequestAs(tc.actor, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil)
|
|
testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(tc.status)
|
|
}
|
|
for _, actor := range []string{memberID, testUserID} {
|
|
runtimeSharingPatch(t, rtID, actor, map[string]any{"shared_with": []string{testUserID}}).Want(http.StatusForbidden)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, rtID) })
|
|
testutil.Call(t, testHandler.CreateAgent, newRequestAs(memberID, http.MethodPost, "/api/agents", map[string]any{
|
|
"name": "Shared runtime recipient", "runtime_id": rtID,
|
|
})).Want(http.StatusCreated)
|
|
testutil.Call(t, testHandler.CreateAgent, newRequestAs(testUserID, http.MethodPost, "/api/agents", map[string]any{
|
|
"name": "Unselected runtime admin", "runtime_id": rtID,
|
|
})).Want(http.StatusForbidden)
|
|
}
|
|
|
|
func TestRuntimeSharingRejectsInvalidRecipients(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Fatal("database-backed fixture required")
|
|
}
|
|
rtID, ownerID, _ := runtimeVisibilityFixture(t)
|
|
outsider := dbfx.Insert(t, "user", testutil.Cols{"name": "Outside workspace", "email": "[email protected]"})
|
|
for _, recipients := range [][]string{{"bad-uuid"}, {outsider}} {
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": recipients, "custom_name": "must not save"}).Want(http.StatusBadRequest)
|
|
}
|
|
req := newRequestAs(outsider, http.MethodPatch, "/api/runtimes/"+rtID, map[string]any{"shared_with": []string{outsider}})
|
|
testutil.Call(t, testHandler.UpdateAgentRuntime, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound)
|
|
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
|
if err != nil || len(rt.SharedWith) != 0 || rt.CustomName.Valid {
|
|
t.Fatalf("invalid input partially saved: %+v, %v", rt, err)
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSharingRevokesOnlyRemovedMembers(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Fatal("database-backed fixture required")
|
|
}
|
|
for _, visibility := range []string{"public", "private"} {
|
|
t.Run(visibility, func(t *testing.T) {
|
|
rtID, ownerID, removedID := runtimeVisibilityFixture(t)
|
|
dbfx.Exec(t, `UPDATE agent_runtime SET visibility = $2, shared_with = ARRAY[$3::uuid, $4::uuid] WHERE id = $1`, rtID, visibility, removedID, testUserID)
|
|
removedAgent := createRuntimePrivacyAgent(t, rtID, removedID, "Removed member agent")
|
|
keptAgent := createRuntimePrivacyAgent(t, rtID, testUserID, "Kept member agent")
|
|
ownAgent := createRuntimePrivacyAgent(t, rtID, ownerID, "Runtime owner agent")
|
|
removedTask := dbfx.Task(t, removedAgent, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
|
keptTask := dbfx.Task(t, keptAgent, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
|
var plan struct {
|
|
Agents []struct {
|
|
ID string `json:"id"`
|
|
} `json:"nonowner_agents"`
|
|
Tasks []string `json:"active_task_ids"`
|
|
}
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private", "shared_with": []string{testUserID}}).Want(http.StatusConflict).JSON(&plan)
|
|
if len(plan.Agents) != 1 || plan.Agents[0].ID != removedAgent || len(plan.Tasks) != 1 || plan.Tasks[0] != removedTask {
|
|
t.Fatalf("wrong revocation plan: %+v", plan)
|
|
}
|
|
runtimePrivacyRequest(t, rtID, ownerID, map[string]any{
|
|
"shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{},
|
|
}).Want(http.StatusConflict)
|
|
runtimePrivacyRequest(t, rtID, ownerID, map[string]any{
|
|
"shared_with": []string{testUserID}, "expected_nonowner_agent_ids": []string{removedAgent}, "expected_task_ids": []string{removedTask},
|
|
}).Want(http.StatusOK)
|
|
for _, agentID := range []string{keptAgent, ownAgent} {
|
|
a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID))
|
|
if err != nil || a.RuntimeID != mustTestUUID(t, rtID) {
|
|
t.Fatalf("kept agent lost binding: %+v %v", a, err)
|
|
}
|
|
}
|
|
for _, tc := range []struct{ id, status string }{{removedTask, "cancelled"}, {keptTask, "running"}} {
|
|
task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, tc.id))
|
|
if err != nil || task.Status != tc.status {
|
|
t.Fatalf("task status=%s, want %s, err=%v", task.Status, tc.status, err)
|
|
}
|
|
}
|
|
a, _ := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, removedAgent))
|
|
if a.RuntimeID.Valid {
|
|
t.Fatal("removed agent remains bound")
|
|
}
|
|
req := newRequestAs(removedID, http.MethodGet, "/api/runtimes/"+rtID+"/usage", nil)
|
|
testutil.Call(t, testHandler.GetRuntimeUsage, testutil.WithURLParams(req, "runtimeId", rtID)).Want(http.StatusNotFound)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSharingOldClientPrivateClearsGrants(t *testing.T) {
|
|
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
|
var rt AgentRuntimeResponse
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"visibility": "private"}).Want(http.StatusOK).JSON(&rt)
|
|
if len(rt.SharedWith) != 0 {
|
|
t.Fatal("explicit old-client private request must clear grants")
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSharingMemberRemovalClearsGrant(t *testing.T) {
|
|
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
|
agentID := createRuntimePrivacyAgent(t, rtID, memberID, "Departing member agent")
|
|
taskID := dbfx.Task(t, agentID, testutil.Cols{"runtime_id": rtID, "status": "running"})
|
|
|
|
member, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
|
WorkspaceID: mustTestUUID(t, testWorkspaceID), UserID: mustTestUUID(t, memberID),
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := newRequest(http.MethodDelete, "/api/workspaces/"+testWorkspaceID+"/members/"+uuidToString(member.ID), nil)
|
|
testutil.Call(t, testHandler.DeleteMember, testutil.WithURLParams(req, "id", testWorkspaceID, "memberId", uuidToString(member.ID))).Want(http.StatusNoContent)
|
|
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
|
if err != nil || len(rt.SharedWith) != 0 {
|
|
t.Fatalf("departed member retained grant: %+v %v", rt.SharedWith, err)
|
|
}
|
|
task, err := testHandler.Queries.GetAgentTask(context.Background(), mustTestUUID(t, taskID))
|
|
if err != nil || task.Status != "cancelled" {
|
|
t.Fatalf("departed member's run survived: %+v %v", task, err)
|
|
}
|
|
a, err := testHandler.Queries.GetAgent(context.Background(), mustTestUUID(t, agentID))
|
|
if err != nil || a.RuntimeID.Valid {
|
|
t.Fatalf("departed member's agent remains bound: %+v %v", a, err)
|
|
}
|
|
|
|
}
|
|
|
|
func TestRuntimeSharingClaimAndReclaim(t *testing.T) {
|
|
for _, reclaim := range []bool{false, true} {
|
|
t.Run(fmt.Sprint("reclaim=", reclaim), func(t *testing.T) {
|
|
ctx := context.Background()
|
|
memberID := dbfx.User(t, "Shared claim member", "shared-claim-"+uuid.NewString()+"@example.com")
|
|
dbfx.Member(t, testWorkspaceID, memberID, "member")
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Shared claim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Shared claim agent")
|
|
dbfx.Exec(t, `UPDATE agent SET owner_id = $1 WHERE id = $2`, memberID, agentID)
|
|
runtimeSharingPatch(t, runtimeID, testUserID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusOK)
|
|
taskID := seedQueuedIssueTask(t, ctx, agentID, runtimeID, issueID)
|
|
if reclaim {
|
|
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'dispatched', dispatched_at = now() - interval '10 minutes', prepare_lease_expires_at = NULL WHERE id = $1`, taskID)
|
|
}
|
|
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil)
|
|
var body struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
} `json:"task"`
|
|
}
|
|
testutil.Call(t, testHandler.ClaimTaskByRuntime, withURLParam(req, "runtimeId", runtimeID)).Want(http.StatusOK).JSON(&body)
|
|
if body.Task == nil || body.Task.ID != taskID {
|
|
t.Fatalf("shared member task not delivered: %+v", body.Task)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuntimeSharingMemberRemovalContention(t *testing.T) {
|
|
rtID, ownerID, memberID := runtimeVisibilityFixture(t)
|
|
tx, err := testPool.Begin(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tx.Rollback(context.Background())
|
|
if _, err := tx.Exec(context.Background(), `SELECT id FROM member WHERE workspace_id = $1 AND user_id = $2 FOR UPDATE`, testWorkspaceID, memberID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtimeSharingPatch(t, rtID, ownerID, map[string]any{"shared_with": []string{memberID}}).Want(http.StatusConflict)
|
|
rt, err := testHandler.Queries.GetAgentRuntime(context.Background(), mustTestUUID(t, rtID))
|
|
if err != nil || len(rt.SharedWith) != 0 {
|
|
t.Fatal("busy grant partially persisted")
|
|
}
|
|
}
|