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) } }