mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(daemon): enforce runtime ownership on daemon routes (MUL-8154) /api/daemon/* accepts any workspace member's PAT/JWT, and authorization only checked workspace membership. Any member could claim another member's tasks (receiving the agent's custom_env, MCP config and an owner-scoped task token), write task results/messages, and take a runtime over by re-registering its daemon_id. - Runtime-level check on every runtime/task daemon route, HTTP heartbeat, deregister, batch claim (and so the WS claim RPC) and the WS handshake: user credentials must own the runtime (NULL owner fails closed); daemon tokens must match a non-empty runtime daemon_id. - Register: pre-check that every existing row on the daemon_id belongs to the caller, plus a required_owner_id fence in both upserts (incl. the failed-profile path) so a concurrent registration cannot win the row. Daemon-token registration keeps the existing owner. - Legacy merge: only for user-credential registrations, and only when the caller owns both rows, verified on the FOR UPDATE-locked rows. - Tasks without a runtime are not writable through daemon routes; status stays readable. - GetTaskStatus / WS lease queries carry runtime owner/daemon so the hot paths keep one round trip. Co-authored-by: multica-agent <[email protected]> * test(daemon): authenticate fixtures as the runtime owner (MUL-8154) Most handler tests drove daemon routes with a daemon token whose daemon_id did not match the fixture runtime, relying on the workspace-wide token scope this fix removes. They now authenticate the way real daemons do, with the runtime owner's user credential (newOwnerDaemonRequest). Tests about daemon token scoping keep mdt_ and get fixtures with a matching daemon_id; the ownerless-runtime tests also assert that a user credential is refused. Co-authored-by: multica-agent <[email protected]> * fix(daemon): serialize registration per daemon and keep Qoder WS claims working (MUL-8154) Review of #9423: - P1: the owner pre-check and the per-provider/profile upserts hit different conflict keys, so two members registering different providers of the same daemon could both pass the pre-check and both insert, leaving one daemon with two owners (and a daemon token for either runtime reaching the other's). Registration now runs the pre-check and every runtime write in one transaction under a pg_advisory_xact_lock on (workspace_id, daemon_id), on a single connection so queued registrations cannot starve the holder. Legacy merges, analytics and the register event run after commit; failed-profile records use savepoints so they stay best-effort. - P2: the Qoder revocation check read the Authorization header, which the WS RPC bridge's in-process request does not carry, so valid Qoder WS claims silently returned nothing. DaemonAuth now records the mdt_ token hash in the request context, the WS identity keeps it, the RPC bridge re-attaches it, and the check validates that hash on every call (revocation still applies to an open connection). - Regression tests for cross-provider and provider+profile concurrent registration, and for valid/revoked Qoder credentials over WS RPC. Co-authored-by: multica-agent <[email protected]> * fix(daemon): bind daemon tokens to the runtime owner they were issued for (MUL-8154) Second review of #9423: a daemon_id can be re-registered by another member after its runtimes are deleted, and a daemon token was bound only to (workspace, daemon_id), so the previous owner's Remote MCP token reached the new owner's runtime (heartbeat, claim, owner-scoped task token). - daemon_token.owner_id (migration 588). Remote MCP tokens are bound to the locked runtime owner inside FinalizeTaskClaim; live tokens are backfilled only where their daemon has exactly one owner. - DaemonAuth carries the owner binding (DB and cache; cache key bumped to v2 so pre-upgrade entries are never read), the WS identity keeps it and the RPC bridge replays it. - Daemon-token authorization now also requires token owner == runtime owner. Qoder-managed tokens stay unbound (governed by cloud_agent_connection), and user credentials can no longer register the reserved qoder-managed-* ids. - Tests: the reuse lifecycle over HTTP, a new WS handshake and an already open WS connection; the reserved daemon_id; the concurrency barrier now hooks the registration transaction (TxStarter) so the interleaving is real. Co-authored-by: multica-agent <[email protected]> * fix(daemon): owner-bind daemon-token registration; drop unsafe token backfill (MUL-8154) Third review of #9423: - Migration 588 no longer backfills daemon_token.owner_id from the daemon's current owner: that owner may be a later re-registrant, which re-bound the previous owner's live token to them. Existing non-Qoder tokens stay NULL and are refused (24h Remote MCP credentials, re-issued on the next claim). - owner_id is a plain UUID column, no foreign key / cascade (AGENTS.md). - Register via a non-Qoder daemon token now uses the token's owner for the in-transaction pre-check and the SQL owner fence, and refuses unbound tokens. Qoder keeps its connection-based check; daemon tokens still never trigger legacy merges. - Tests: an old owner's bound token and an unbound legacy token can neither register over nor heartbeat the replacement owner's runtime. Co-authored-by: multica-agent <[email protected]> * fix(daemon): grant the Qoder registration exemption only with a live connection (MUL-8154) Fourth review of #9423: register skipped the token owner binding for any qoder-managed-* daemon token, but checked the Qoder connection only when the body declared a qoder_cloud runtime. A pre-upgrade Remote MCP token under that prefix (no owner, no connection) could register type=claude and rewrite another member's runtime. Register now verifies the token against a live cloud_agent_connection before anything else whenever the token is in the Qoder namespace, regardless of the declared runtimes, and restricts that namespace to the bridge's own shape: a built-in qoder_cloud runtime, no custom profiles, no failed_profiles. Test covers built-in, custom-profile, failed-profiles-only and qoder_cloud bodies with an unconnected token, plus the connected token's allowed and refused shapes. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
246 lines
12 KiB
Go
246 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/multica-ai/multica/server/internal/issuestatus"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// These are the installed Web/Desktop consumer's literal accepted buckets,
|
|
// deliberately independent of the server's current category constants.
|
|
var installedStatusBuckets = []string{"backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"}
|
|
|
|
// Historical storage matrices are exercised by TestStatusCategoryContractUpgradePaths.
|
|
// API assertions here run against the fully migrated production schema.
|
|
func TestStatusCompatibilityContractedStorageGroupsAndPagination(t *testing.T) {
|
|
for _, storage := range []string{"contracted"} {
|
|
t.Run(storage, func(t *testing.T) {
|
|
project := dbfx.Insert(t, "project", testutil.Cols{"workspace_id": testWorkspaceID, "title": "Compatibility " + storage})
|
|
suffix := uuid.NewString()[:8]
|
|
expectedWire := map[string]string{}
|
|
expectedLifecycle := map[string]string{}
|
|
for i, old := range installedStatusBuckets {
|
|
category, _ := issuestatus.CategoryForBehavior(old)
|
|
stored := category
|
|
key := fmt.Sprintf("compat_%s_%d", suffix, i)
|
|
cols := testutil.Cols{"workspace_id": testWorkspaceID, "key": key, "name": key, "category": stored, "color": "#123456"}
|
|
if old == "cancelled" {
|
|
cols["archived_at"] = testutil.Raw("now()")
|
|
}
|
|
dbfx.Insert(t, "issue_status", cols)
|
|
for _, status := range []string{key, old} {
|
|
dbfx.Issue(t, status, testutil.Cols{"status": status, "project_id": project})
|
|
expectedWire[status] = issuestatus.WireCategory(status, category)
|
|
expectedLifecycle[status] = category
|
|
}
|
|
keys, err := issuestatus.ExpandCategories(context.Background(), testHandler.Queries, parseUUID(testWorkspaceID), []string{category})
|
|
if err != nil || !slices.Contains(keys, key) {
|
|
t.Fatalf("category filter dropped %s: %v, %v", key, keys, err)
|
|
}
|
|
}
|
|
query := statusCategoryQuery(project)
|
|
for _, spec := range []issueTableGroupSpec{
|
|
{Kind: "status_category"},
|
|
{Kind: "compound", Primary: "project", Secondary: "status_category"},
|
|
{Kind: "status_category", CategoryFormat: "lifecycle"},
|
|
{Kind: "compound", Primary: "project", Secondary: "status_category", CategoryFormat: "lifecycle"},
|
|
{Kind: "status"},
|
|
} {
|
|
t.Run(spec.Kind+"/"+spec.CategoryFormat, func(t *testing.T) {
|
|
var groups issueTableGroupsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableGroups, newRequest(http.MethodPost, "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: query, Group: spec, Page: issueTablePageRequest{Limit: 100},
|
|
})).Want(http.StatusOK).JSON(&groups)
|
|
if groups.Total != 14 {
|
|
t.Fatalf("total=%d, want 14", groups.Total)
|
|
}
|
|
cells := groups.Groups
|
|
if spec.Kind == "compound" {
|
|
if len(cells) != 1 {
|
|
t.Fatalf("lanes=%d", len(cells))
|
|
}
|
|
cells = cells[0].SecondaryGroups
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, cell := range cells {
|
|
legacy := spec.Kind != "status" && spec.CategoryFormat == ""
|
|
if legacy && !slices.Contains(installedStatusBuckets, cell.Value.Status) {
|
|
t.Fatalf("installed consumer hides %q", cell.Value.Status)
|
|
}
|
|
var cursor *string
|
|
var count int64
|
|
for page := 0; page < 20; page++ {
|
|
var rows issueTableRowsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableRows, newRequest(http.MethodPost, "/api/issues/table/rows", issueTableRowsRequest{
|
|
Query: query, Group: spec, GroupKey: &cell.Key, Page: issueTablePageRequest{Limit: 1, Cursor: cursor},
|
|
})).Want(http.StatusOK).JSON(&rows)
|
|
for _, row := range rows.Rows {
|
|
status := row.Issue.Status
|
|
if seen[status] {
|
|
t.Fatalf("duplicate paged card: %s", status)
|
|
}
|
|
seen[status] = true
|
|
want := status
|
|
if legacy {
|
|
want = expectedWire[status]
|
|
} else if spec.Kind != "status" {
|
|
want = expectedLifecycle[status]
|
|
}
|
|
if cell.Value.Status != want {
|
|
t.Fatalf("card %s belongs to %s, got %s", status, want, cell.Value.Status)
|
|
}
|
|
if row.Issue.StatusCategory != expectedWire[status] {
|
|
t.Fatalf("row category drift for %s: %s", status, row.Issue.StatusCategory)
|
|
}
|
|
count++
|
|
}
|
|
cursor = rows.NextCursor
|
|
if cursor == nil {
|
|
break
|
|
}
|
|
if page == 19 {
|
|
t.Fatal("pagination did not terminate")
|
|
}
|
|
}
|
|
if count != cell.Count {
|
|
t.Fatalf("bucket %s: header=%d rows=%d", cell.Value.Status, cell.Count, count)
|
|
}
|
|
}
|
|
if len(seen) != 14 {
|
|
t.Fatalf("visible cards=%d, want 14", len(seen))
|
|
}
|
|
})
|
|
}
|
|
// A legacy client can show only In Review: it must not acquire the
|
|
// In Progress/Blocked cards merely because all share lifecycle Started.
|
|
var filtered issueTableGroupsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableGroups, newRequest(http.MethodPost, "/api/issues/table/groups", issueTableGroupsRequest{
|
|
Query: query, Group: issueTableGroupSpec{Kind: "compound", Primary: "project", Secondary: "status_category", SecondaryValues: []string{"in_review"}}, Page: issueTablePageRequest{Limit: 1},
|
|
})).Want(http.StatusOK).JSON(&filtered)
|
|
if filtered.Total != 1 {
|
|
t.Fatalf("visible In Review cards=%d, want 1", filtered.Total)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStatusCompatibilityCatalogWritesAndTenantIsolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
key := "legacy_" + uuid.NewString()[:8]
|
|
id := dbfx.Insert(t, "issue_status", testutil.Cols{"workspace_id": testWorkspaceID, "key": key, "name": key, "category": "started", "color": "#123456", "position": 99999})
|
|
// Position allocation and old API inputs share the contracted lifecycle.
|
|
created, err := testHandler.Queries.CreateIssueStatusEntry(ctx, db.CreateIssueStatusEntryParams{
|
|
WorkspaceID: parseUUID(testWorkspaceID), Key: key + "_new", Name: "New", Category: "started", Color: "#123456",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dbfx.Cleanup(t, "DELETE FROM issue_status WHERE id=$1", created.ID)
|
|
if created.Position != 100000 {
|
|
t.Fatalf("new position=%v, want 100000", created.Position)
|
|
}
|
|
catalog, err := testHandler.Queries.ListIssueStatusEntries(ctx, db.ListIssueStatusEntriesParams{WorkspaceID: parseUUID(testWorkspaceID)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids := []string{}
|
|
for _, entry := range catalog {
|
|
if entry.Category == "started" && !entry.IsSystem {
|
|
ids = append(ids, uuid.UUID(entry.ID.Bytes).String())
|
|
}
|
|
}
|
|
testutil.Call(t, testHandler.ReorderIssueStatuses, newRequest(http.MethodPost, "/api/issue-statuses/reorder", ReorderIssueStatusesRequest{Category: "in_review", IDs: ids})).Want(http.StatusOK)
|
|
var stored string
|
|
dbfx.QueryRow(t, "SELECT category FROM issue_status WHERE id=$1", id).Scan(&stored)
|
|
if stored != "started" {
|
|
t.Fatalf("reorder changed lifecycle category %s", stored)
|
|
}
|
|
foreign := dbfx.Workspace(t, "Other", "compat-"+uuid.NewString()[:8])
|
|
foreignKey := "foreign_" + uuid.NewString()[:8]
|
|
dbfx.Insert(t, "issue_status", testutil.Cols{"workspace_id": foreign, "key": foreignKey, "name": "Foreign", "category": "closed", "color": "#123456"})
|
|
keys, err := issuestatus.ExpandCategories(ctx, testHandler.Queries, parseUUID(testWorkspaceID), []string{"closed"})
|
|
if err != nil || slices.Contains(keys, foreignKey) {
|
|
t.Fatalf("cross-workspace filter expansion: %v, %v", keys, err)
|
|
}
|
|
if got := issuestatus.Effective(ctx, testHandler.Queries, parseUUID(testWorkspaceID), foreignKey); got != foreignKey {
|
|
t.Fatalf("cross-workspace terminal behavior: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestStatusCompatibilityGCAfterContract(t *testing.T) {
|
|
for _, old := range installedStatusBuckets {
|
|
category, _ := issuestatus.CategoryForBehavior(old)
|
|
for _, stored := range []string{category} {
|
|
t.Run(old+"/"+stored, func(t *testing.T) {
|
|
key := "gc_" + uuid.NewString()[:8]
|
|
dbfx.Insert(t, "issue_status", testutil.Cols{"workspace_id": testWorkspaceID, "key": key, "name": key, "category": stored, "color": "#123456"})
|
|
id := dbfx.Issue(t, key, testutil.Cols{"status": key})
|
|
type result struct {
|
|
Status string `json:"status"`
|
|
Category string `json:"category"`
|
|
}
|
|
check := func(got result) {
|
|
t.Helper()
|
|
if got.Status != issuestatus.WireCategory(key, category) || got.Category != category {
|
|
t.Fatalf("GC wire=%+v for stored %s", got, stored)
|
|
}
|
|
}
|
|
var single result
|
|
req := withURLParam(newOwnerDaemonRequest("GET", "/api/daemon/issues/"+id+"/gc-check", nil), "issueId", id)
|
|
testutil.Call(t, testHandler.GetIssueGCCheck, req).Want(http.StatusOK).JSON(&single)
|
|
check(single)
|
|
var batch struct {
|
|
Issues []result `json:"issues"`
|
|
}
|
|
req = withURLParam(newOwnerDaemonRequest("POST", "/api/daemon/workspaces/"+testWorkspaceID+"/issues/gc-check", map[string]any{"issue_ids": []string{id}}), "workspaceId", testWorkspaceID)
|
|
testutil.Call(t, testHandler.BatchIssueGCCheck, req).Want(http.StatusOK).JSON(&batch)
|
|
if len(batch.Issues) != 1 {
|
|
t.Fatalf("GC results=%d", len(batch.Issues))
|
|
}
|
|
check(batch.Issues[0])
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStatusCompatibilityCursorRejectsDifferentCategoryFormat(t *testing.T) {
|
|
project, _ := seedStatusCategoryFixture(t)
|
|
key := "status_category:in_progress"
|
|
request := issueTableRowsRequest{Query: statusCategoryQuery(project), Group: issueTableGroupSpec{Kind: "status_category"}, GroupKey: &key, Page: issueTablePageRequest{Limit: 1}}
|
|
var rows issueTableRowsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableRows, newRequest(http.MethodPost, "/api/issues/table/rows", request)).Want(http.StatusOK).JSON(&rows)
|
|
if rows.NextCursor == nil {
|
|
t.Fatal("expected a second legacy page")
|
|
}
|
|
request.Page.Cursor = rows.NextCursor
|
|
request.Group.CategoryFormat = "lifecycle"
|
|
testutil.Call(t, testHandler.ListIssueTableRows, newRequest(http.MethodPost, "/api/issues/table/rows", request)).Want(http.StatusConflict)
|
|
}
|
|
|
|
func TestStatusCompatibilityParentLaneUsesExpandedVisibleKeys(t *testing.T) {
|
|
project := dbfx.Insert(t, "project", testutil.Cols{"workspace_id": testWorkspaceID, "title": "Category parent lane"})
|
|
key := "child_" + uuid.NewString()[:8]
|
|
dbfx.Insert(t, "issue_status", testutil.Cols{"workspace_id": testWorkspaceID, "key": key, "name": key, "category": "started", "color": "#123456"})
|
|
parent := dbfx.Issue(t, "Parent", testutil.Cols{"project_id": project, "status": "in_progress"})
|
|
dbfx.Issue(t, "Child", testutil.Cols{"project_id": project, "status": key, "parent_issue_id": parent})
|
|
group := issueTableGroupSpec{Kind: "compound", Primary: "parent", Secondary: "status_category", SecondaryValues: []string{"in_progress"}}
|
|
var groups issueTableGroupsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableGroups, newRequest(http.MethodPost, "/api/issues/table/groups", issueTableGroupsRequest{Query: statusCategoryQuery(project), Group: group, Page: issueTablePageRequest{Limit: 10}})).Want(http.StatusOK).JSON(&groups)
|
|
if groups.Total != 1 || len(groups.Groups) != 1 || groups.Groups[0].Key != "parent:"+parent {
|
|
t.Fatalf("visible parent groups = %+v", groups)
|
|
}
|
|
noParent := compoundCellGroupKey("parent:none", "in_progress", true)
|
|
var rows issueTableRowsResponse
|
|
testutil.Call(t, testHandler.ListIssueTableRows, newRequest(http.MethodPost, "/api/issues/table/rows", issueTableRowsRequest{Query: statusCategoryQuery(project), Group: group, GroupKey: &noParent, Page: issueTablePageRequest{Limit: 10}})).Want(http.StatusOK).JSON(&rows)
|
|
if len(rows.Rows) != 0 {
|
|
t.Fatalf("promoted parent duplicated in No parent: %+v", rows.Rows)
|
|
}
|
|
}
|