mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
GetActiveTaskForIssue, CancelTask, ListTasksByIssue, and GetIssueUsage accepted the issueId URL parameter and queried by it without verifying that the issue belonged to the caller's X-Workspace-ID workspace. The RequireWorkspaceMember middleware only proves membership in the header workspace; it does not bind the path-parameter issue to it. A member of workspace A could therefore enumerate tasks, cancel tasks, and read usage metadata for any issue UUID in workspace B. Route every issueId through loadIssueForUser (matching GetIssue and the existing comment/subscriber handlers). For CancelTask additionally verify that the task's IssueID matches the loaded issue — the task must not only belong to the caller's workspace but also to the specific issue named in the URL, and the access check must run before any mutation. Follow-up to MUL-899 / #1112.
647 lines
24 KiB
Go
647 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
)
|
|
|
|
func setHandlerTestWorkspaceRepos(t *testing.T, repos []map[string]string) {
|
|
t.Helper()
|
|
data, err := json.Marshal(repos)
|
|
if err != nil {
|
|
t.Fatalf("marshal repos: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, data, testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte("[]"), testWorkspaceID); err != nil {
|
|
t.Fatalf("reset workspace repos: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// newDaemonTokenRequest creates an HTTP request with daemon token context set
|
|
// (simulating DaemonAuth middleware for mdt_ tokens).
|
|
func newDaemonTokenRequest(method, path string, body any, workspaceID, daemonID string) *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")
|
|
// No X-User-ID — daemon tokens don't set it.
|
|
ctx := middleware.WithDaemonContext(req.Context(), workspaceID, daemonID)
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
func TestDaemonRegister_WithDaemonToken(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-mdt",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
}, testWorkspaceID, "test-daemon-mdt")
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister with daemon token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
runtimes, ok := resp["runtimes"].([]any)
|
|
if !ok || len(runtimes) == 0 {
|
|
t.Fatalf("DaemonRegister: expected runtimes in response, got %v", resp)
|
|
}
|
|
if _, ok := resp["repos_version"].(string); !ok {
|
|
t.Fatalf("DaemonRegister: expected repos_version in response, got %v", resp)
|
|
}
|
|
|
|
// Clean up: deregister the runtime.
|
|
rt := runtimes[0].(map[string]any)
|
|
runtimeID := rt["id"].(string)
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
}
|
|
|
|
func TestDaemonRegister_WithDaemonToken_WorkspaceMismatch(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
// Daemon token is for a different workspace than the request body.
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-mdt",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
}, "00000000-0000-0000-0000-000000000000", "test-daemon-mdt")
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonRegister with mismatched workspace: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonHeartbeat_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// First, register a runtime using PAT (existing flow).
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-heartbeat",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime-hb", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Setup: DaemonRegister failed: %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var regResp map[string]any
|
|
json.NewDecoder(w.Body).Decode(®Resp)
|
|
runtimes := regResp["runtimes"].([]any)
|
|
runtimeID := runtimes[0].(map[string]any)["id"].(string)
|
|
defer testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
|
|
// Try heartbeat with a daemon token from a DIFFERENT workspace — should fail.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
}, "00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
|
|
testHandler.DaemonHeartbeat(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonHeartbeat with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetTaskStatus_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Create a task in the test workspace.
|
|
var issueID, taskID string
|
|
err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'daemon-auth-test-issue', 'todo', 'medium', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
|
|
// Get an agent and runtime from the test workspace.
|
|
var agentID, runtimeID string
|
|
err = testPool.QueryRow(context.Background(), `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID)
|
|
if err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
err = testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
|
|
// Try GetTaskStatus with a daemon token from a DIFFERENT workspace — should fail.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/status", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetTaskStatus with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same request with the CORRECT workspace should succeed.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/status", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = req.WithContext(context.WithValue(
|
|
middleware.WithDaemonContext(req.Context(), testWorkspaceID, "legit-daemon"),
|
|
chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetTaskStatus with correct workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetIssueGCCheck_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Create an issue in the test workspace. The daemon GC endpoint returns
|
|
// only status + updated_at, so a "done" issue exercises the typical path.
|
|
var issueID string
|
|
err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'gc-check-auth-test-issue', 'done', 'medium', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
|
|
// Cross-workspace daemon token must be rejected with 404 — same status
|
|
// code as "issue not found" so there is no UUID enumeration oracle.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/issues/"+issueID+"/gc-check", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
req = withURLParam(req, "issueId", issueID)
|
|
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetIssueGCCheck with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace daemon token succeeds and returns status + updated_at.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/issues/"+issueID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "issueId", issueID)
|
|
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetIssueGCCheck with correct workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Status != "done" {
|
|
t.Fatalf("expected status %q, got %q", "done", resp.Status)
|
|
}
|
|
if resp.UpdatedAt == "" {
|
|
t.Fatal("expected updated_at to be set")
|
|
}
|
|
}
|
|
|
|
// withURLParams merges the given chi URL parameters into the request context.
|
|
// Unlike calling withURLParam twice (which replaces the whole chi.RouteContext
|
|
// and loses earlier params), this preserves previously-added params.
|
|
func withURLParams(req *http.Request, kv ...string) *http.Request {
|
|
rctx := chi.NewRouteContext()
|
|
if existing, ok := req.Context().Value(chi.RouteCtxKey).(*chi.Context); ok && existing != nil {
|
|
for i, key := range existing.URLParams.Keys {
|
|
rctx.URLParams.Add(key, existing.URLParams.Values[i])
|
|
}
|
|
}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
rctx.URLParams.Add(kv[i], kv[i+1])
|
|
}
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
// setupForeignWorkspaceFixture creates an isolated workspace (not reachable
|
|
// from testUserID) with its own agent, runtime, issue, and queued task.
|
|
// Returns (issueID, taskID). All rows are cleaned up when the test ends.
|
|
func setupForeignWorkspaceFixture(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
var foreignWorkspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Foreign Workspace", "foreign-idor-tests", "Cross-tenant IDOR test workspace", "FOR").Scan(&foreignWorkspaceID); err != nil {
|
|
t.Fatalf("setup: create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
|
|
})
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
|
RETURNING id
|
|
`, foreignWorkspaceID, "Foreign Runtime", "foreign_runtime", "Foreign runtime").Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create foreign runtime: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1)
|
|
RETURNING id
|
|
`, foreignWorkspaceID, "Foreign Agent", runtimeID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: create foreign agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'foreign-workspace-issue', 'todo', 'medium', $2, 'agent')
|
|
RETURNING id
|
|
`, foreignWorkspaceID, agentID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create foreign issue: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create foreign task: %v", err)
|
|
}
|
|
|
|
return issueID, taskID
|
|
}
|
|
|
|
// TestGetActiveTaskForIssue_CrossWorkspace_Returns404 verifies that a member of
|
|
// workspace A cannot discover tasks for an issue in workspace B by passing
|
|
// B's issue UUID in the URL while keeping A in X-Workspace-ID.
|
|
func TestGetActiveTaskForIssue_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/active-task", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.GetActiveTaskForIssue(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetActiveTaskForIssue with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_CrossWorkspace_Returns404 verifies that a member of workspace
|
|
// A cannot cancel a task that lives in workspace B. Critically, the task must
|
|
// remain in its original status — no side effect before the access check.
|
|
func TestCancelTask_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, foreignTaskID := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+foreignIssueID+"/tasks/"+foreignTaskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", foreignIssueID, "taskId", foreignTaskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("CancelTask with cross-workspace issueId/taskId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The foreign task must not have been cancelled.
|
|
var status string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, foreignTaskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read foreign task status: %v", err)
|
|
}
|
|
if status != "queued" {
|
|
t.Fatalf("foreign task status was mutated: expected 'queued', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_TaskBelongsToDifferentIssue_Returns404 verifies that a task
|
|
// UUID belonging to a *different* issue in the *same* accessible workspace
|
|
// cannot be cancelled by routing it through another issue's URL. This guards
|
|
// against the weaker fix that only validates the issue→workspace binding.
|
|
func TestCancelTask_TaskBelongsToDifferentIssue_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
// Issue X — the task's real parent.
|
|
var issueXID, taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-crossissue-x', 'todo', 'medium', $2, 'member', 91001, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueXID); err != nil {
|
|
t.Fatalf("setup: create issue X: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueXID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueXID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
// Issue Y — a sibling in the same workspace, used only as the URL cover.
|
|
var issueYID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-crossissue-y', 'todo', 'medium', $2, 'member', 91002, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueYID); err != nil {
|
|
t.Fatalf("setup: create issue Y: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueYID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueYID+"/tasks/"+taskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", issueYID, "taskId", taskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("CancelTask with mismatched issueId/taskId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, taskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read task status: %v", err)
|
|
}
|
|
if status != "queued" {
|
|
t.Fatalf("task status was mutated: expected 'queued', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_SameIssue_Succeeds is the happy-path companion to the two
|
|
// negative tests above — same workspace, correct issue→task pairing → 200.
|
|
func TestCancelTask_SameIssue_Succeeds(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var issueID, taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-happy-path', 'todo', 'medium', $2, 'member', 91003, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/tasks/"+taskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", issueID, "taskId", taskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CancelTask with matching issueId/taskId: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestListTasksByIssue_CrossWorkspace_Returns404 verifies that task history
|
|
// is not readable across workspaces via a bare issue UUID.
|
|
func TestListTasksByIssue_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/task-runs", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.ListTasksByIssue(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("ListTasksByIssue with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetIssueUsage_CrossWorkspace_Returns404 verifies that per-issue token
|
|
// usage is not readable across workspaces via a bare issue UUID.
|
|
func TestGetIssueUsage_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/usage", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.GetIssueUsage(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetIssueUsage with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_WithDaemonToken(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "git@example.com:team/api.git", "description": "API"},
|
|
{"url": " git@example.com:team/web.git ", "description": " Web "},
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Repos []map[string]string `json:"repos"`
|
|
ReposVersion string `json:"repos_version"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if resp.WorkspaceID != testWorkspaceID {
|
|
t.Fatalf("expected workspace_id %s, got %s", testWorkspaceID, resp.WorkspaceID)
|
|
}
|
|
if len(resp.Repos) != 2 {
|
|
t.Fatalf("expected 2 repos, got %d", len(resp.Repos))
|
|
}
|
|
if resp.Repos[1]["url"] != "git@example.com:team/web.git" {
|
|
t.Fatalf("expected trimmed repo URL, got %q", resp.Repos[1]["url"])
|
|
}
|
|
if resp.ReposVersion == "" {
|
|
t.Fatal("expected repos_version to be set")
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_WithDaemonToken_WorkspaceMismatch(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, "00000000-0000-0000-0000-000000000000", "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetDaemonWorkspaceRepos with mismatched workspace: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_VersionIgnoresOrderAndDescription(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "git@example.com:team/api.git", "description": "API"},
|
|
{"url": "git@example.com:team/web.git", "description": "Web"},
|
|
})
|
|
|
|
getReposVersion := func() string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
ReposVersion string `json:"repos_version"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return resp.ReposVersion
|
|
}
|
|
|
|
version1 := getReposVersion()
|
|
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/web.git","description":"frontend"},{"url":"git@example.com:team/api.git","description":"backend"}]`), testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
version2 := getReposVersion()
|
|
if version1 != version2 {
|
|
t.Fatalf("expected repos_version to ignore order/description changes, got %s vs %s", version1, version2)
|
|
}
|
|
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/api.git","description":"backend"},{"url":"git@example.com:team/mobile.git","description":"mobile"}]`), testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
version3 := getReposVersion()
|
|
if strings.EqualFold(version2, version3) {
|
|
t.Fatalf("expected repos_version to change when URL set changes, got %s", version3)
|
|
}
|
|
}
|