mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* fix(agents): let workspace members view runtime capabilities (MUL-4427) The Agent capabilities redesign (#5277) reused the runtime local-skills discovery endpoint on Agent detail surfaces, but the endpoint kept the owner-only gate from the original import flow. Viewing an agent bound to someone else's runtime returned 403, which the Skills / MCP tabs rendered as 'try again when the runtime is online' even though the runtime was online. - Discovery (list + poll) now requires workspace membership only; the payload is the deliberately redacted inventory built for this display. - Import (init + poll) stays owner-only: it copies skill file contents off the owner's machine. - The failed notice no longer blames runtime connectivity, and a 403 (new client against an older backend) gets an honest permission message. * test(settings): stub Intl.supportedValuesOf in timezone picker tests The preferences-tab timezone tests drove a ~600-option Base UI Select through userEvent in jsdom; on slow CI runners the clear-preference case exceeded even its extended 20s per-test timeout (PR #5281 frontend job). Stub the IANA enumeration down to the curated COMMON_TIMEZONES fallback — everything the tests pick lives there too — and drop the now-unneeded 20s overrides. File test time drops from ~35s to under 1s. --------- Co-authored-by: Lambda <lambda@multica.ai>
673 lines
21 KiB
Go
673 lines
21 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newRequestAsUser(userID, method, path string, body any) *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")
|
|
req.Header.Set("X-User-ID", userID)
|
|
req.Header.Set("X-Workspace-ID", testWorkspaceID)
|
|
return req
|
|
}
|
|
|
|
func createRuntimeLocalSkillTestRuntime(t *testing.T, ownerID string) string {
|
|
t.Helper()
|
|
|
|
runtimeName := fmt.Sprintf("runtime-local-skill-%d", time.Now().UnixNano())
|
|
daemonID := fmt.Sprintf("runtime-local-skill-daemon-%d", time.Now().UnixNano())
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at
|
|
)
|
|
VALUES ($1, $2, $3, 'local', 'claude', 'online', 'Runtime Local Skills Test', '{}'::jsonb, $4, now())
|
|
RETURNING id
|
|
`, testWorkspaceID, daemonID, runtimeName, ownerID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("create local runtime: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
})
|
|
|
|
return runtimeID
|
|
}
|
|
|
|
func createRuntimeLocalSkillTestMember(t *testing.T, role string) string {
|
|
t.Helper()
|
|
|
|
email := fmt.Sprintf("runtime-local-skills-%d@multica.ai", time.Now().UnixNano())
|
|
name := fmt.Sprintf("Runtime Local Skills %s", role)
|
|
|
|
var userID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO "user" (name, email)
|
|
VALUES ($1, $2)
|
|
RETURNING id
|
|
`, name, email).Scan(&userID); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
if _, err := testPool.Exec(context.Background(), `
|
|
INSERT INTO member (workspace_id, user_id, role)
|
|
VALUES ($1, $2, $3)
|
|
`, testWorkspaceID, userID, role); err != nil {
|
|
t.Fatalf("create member: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
|
|
})
|
|
|
|
return userID
|
|
}
|
|
|
|
func countSkillsByName(t *testing.T, name string) int {
|
|
t.Helper()
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*)
|
|
FROM skill
|
|
WHERE workspace_id = $1 AND name = $2
|
|
`, testWorkspaceID, name).Scan(&count); err != nil {
|
|
t.Fatalf("count skills: %v", err)
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func countSkillFiles(t *testing.T, skillID string) int {
|
|
t.Helper()
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
SELECT count(*)
|
|
FROM skill_file
|
|
WHERE skill_id = $1
|
|
`, skillID).Scan(&count); err != nil {
|
|
t.Fatalf("count skill files: %v", err)
|
|
}
|
|
|
|
return count
|
|
}
|
|
|
|
func TestInMemoryLocalSkillListStore_PreservesSummaries(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewInMemoryLocalSkillListStore()
|
|
req, err := store.Create(ctx, "runtime-xyz")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
body := map[string]any{
|
|
"status": "completed",
|
|
"supported": true,
|
|
"skills": []map[string]any{
|
|
{
|
|
"key": "paper-desktop:review-helper",
|
|
"name": "paper-desktop:review-helper",
|
|
"description": "Review PRs",
|
|
"source_path": "~/.claude/plugins/cache/paper/skills/review-helper",
|
|
"provider": "claude",
|
|
"root": "plugin",
|
|
"plugin": "paper-desktop@paper",
|
|
"file_count": 2,
|
|
},
|
|
},
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
|
|
var parsed struct {
|
|
Skills []RuntimeLocalSkillSummary `json:"skills"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
t.Fatalf("unmarshal report body: %v", err)
|
|
}
|
|
|
|
if err := store.Complete(ctx, req.ID, parsed.Skills, true, nil, false); err != nil {
|
|
t.Fatalf("complete: %v", err)
|
|
}
|
|
got, err := store.Get(ctx, req.ID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected stored result")
|
|
}
|
|
if len(got.Skills) != 1 {
|
|
t.Fatalf("expected 1 skill, got %d", len(got.Skills))
|
|
}
|
|
if got.Skills[0].SourcePath != "~/.claude/plugins/cache/paper/skills/review-helper" {
|
|
t.Fatalf("source_path = %q", got.Skills[0].SourcePath)
|
|
}
|
|
if got.Skills[0].Root != "plugin" || got.Skills[0].Plugin != "paper-desktop@paper" {
|
|
t.Fatalf("plugin origin = %#v", got.Skills[0])
|
|
}
|
|
if got.Skills[0].FileCount != 2 {
|
|
t.Fatalf("file_count = %d", got.Skills[0].FileCount)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryLocalSkillListStore_TimesOutRunningRequests(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewInMemoryLocalSkillListStore()
|
|
req, err := store.Create(ctx, "runtime-xyz")
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
req.Status = RuntimeLocalSkillRunning
|
|
startedAt := time.Now().Add(-61 * time.Second)
|
|
req.RunStartedAt = &startedAt
|
|
|
|
got, err := store.Get(ctx, req.ID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected stored request")
|
|
}
|
|
if got.Status != RuntimeLocalSkillTimeout {
|
|
t.Fatalf("expected timeout, got %s", got.Status)
|
|
}
|
|
if got.Error == "" {
|
|
t.Fatal("expected timeout error")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryLocalSkillImportStore_TimesOutRunningRequests(t *testing.T) {
|
|
ctx := context.Background()
|
|
store := NewInMemoryLocalSkillImportStore()
|
|
req, err := store.Create(ctx, LocalSkillImportRequestInput{
|
|
RuntimeID: "runtime-xyz",
|
|
CreatorID: "user-1",
|
|
SkillKey: "review-helper",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
req.Status = RuntimeLocalSkillRunning
|
|
startedAt := time.Now().Add(-61 * time.Second)
|
|
req.RunStartedAt = &startedAt
|
|
|
|
got, err := store.Get(ctx, req.ID)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected stored request")
|
|
}
|
|
if got.Status != RuntimeLocalSkillTimeout {
|
|
t.Fatalf("expected timeout, got %s", got.Status)
|
|
}
|
|
if got.Error == "" {
|
|
t.Fatal("expected timeout error")
|
|
}
|
|
}
|
|
|
|
// Capability discovery (list + poll) is readable by any workspace member so
|
|
// the Agent capabilities surfaces work for agents bound to someone else's
|
|
// runtime. Import stays owner-only (tests below).
|
|
func TestListLocalSkills_AllowsNonOwnerWorkspaceMember(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
memberUserID := createRuntimeLocalSkillTestMember(t, "member")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(memberUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills", nil),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
|
|
testHandler.InitiateListLocalSkills(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var initResp RuntimeLocalSkillListRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&initResp); err != nil {
|
|
t.Fatalf("decode initiate response: %v", err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
pollReq := withURLParams(
|
|
newRequestAsUser(memberUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/"+initResp.ID, nil),
|
|
"runtimeId", runtimeID,
|
|
"requestId", initResp.ID,
|
|
)
|
|
|
|
testHandler.GetLocalSkillListRequest(w, pollReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListLocalSkills_RejectsNonMember(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
|
|
var outsiderID string
|
|
email := fmt.Sprintf("runtime-local-skills-outsider-%d@multica.ai", time.Now().UnixNano())
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO "user" (name, email)
|
|
VALUES ('Runtime Local Skills Outsider', $1)
|
|
RETURNING id
|
|
`, email).Scan(&outsiderID); err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, outsiderID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(outsiderID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills", nil),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
|
|
testHandler.InitiateListLocalSkills(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInitiateImportLocalSkill_RequiresRuntimeOwner(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
adminUserID := createRuntimeLocalSkillTestMember(t, "admin")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(adminUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", map[string]any{
|
|
"skill_key": "review-helper",
|
|
}),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
|
|
testHandler.InitiateImportLocalSkill(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetLocalSkillImportRequest_RequiresRuntimeOwner(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
adminUserID := createRuntimeLocalSkillTestMember(t, "admin")
|
|
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
|
|
RuntimeID: runtimeID,
|
|
CreatorID: testUserID,
|
|
SkillKey: "review-helper",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create import request: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(adminUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/import/"+importReq.ID, nil),
|
|
"runtimeId", runtimeID,
|
|
"requestId", importReq.ID,
|
|
)
|
|
|
|
testHandler.GetLocalSkillImportRequest(w, req)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRuntimeLocalSkillImportFlow_EndToEnd(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
|
|
w := httptest.NewRecorder()
|
|
initReq := withURLParams(
|
|
newRequestAsUser(testUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", map[string]any{
|
|
"skill_key": "review-helper",
|
|
"name": "Imported Review Helper",
|
|
"description": "Imported description",
|
|
}),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
testHandler.InitiateImportLocalSkill(w, initReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("InitiateImportLocalSkill: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var importReq RuntimeLocalSkillImportRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&importReq); err != nil {
|
|
t.Fatalf("decode import request: %v", err)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
heartbeatReq := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
}, testWorkspaceID, "runtime-local-skills-daemon")
|
|
testHandler.DaemonHeartbeat(w, heartbeatReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonHeartbeat: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var heartbeatResp map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&heartbeatResp); err != nil {
|
|
t.Fatalf("decode heartbeat response: %v", err)
|
|
}
|
|
pending, ok := heartbeatResp["pending_local_skill_import"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected pending_local_skill_import, got %v", heartbeatResp)
|
|
}
|
|
if pending["id"] != importReq.ID {
|
|
t.Fatalf("pending id = %v, want %s", pending["id"], importReq.ID)
|
|
}
|
|
if pending["skill_key"] != "review-helper" {
|
|
t.Fatalf("pending skill_key = %v", pending["skill_key"])
|
|
}
|
|
if _, ok := pending["name"]; ok {
|
|
t.Fatalf("heartbeat payload should not include name: %v", pending)
|
|
}
|
|
if _, ok := pending["description"]; ok {
|
|
t.Fatalf("heartbeat payload should not include description: %v", pending)
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
reportReq := withURLParams(
|
|
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+importReq.ID+"/result", map[string]any{
|
|
"status": "completed",
|
|
"skill": map[string]any{
|
|
"name": "Original Review Helper",
|
|
"description": "Original description",
|
|
"content": "# Review Helper",
|
|
"source_path": "~/.claude/skills/review-helper",
|
|
"provider": "claude",
|
|
"files": []map[string]any{
|
|
{
|
|
"path": "templates/check.md",
|
|
"content": "body",
|
|
},
|
|
},
|
|
},
|
|
}, testWorkspaceID, "runtime-local-skills-daemon"),
|
|
"runtimeId", runtimeID,
|
|
"requestId", importReq.ID,
|
|
)
|
|
testHandler.ReportLocalSkillImportResult(w, reportReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ReportLocalSkillImportResult: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
pollReq := withURLParams(
|
|
newRequestAsUser(testUserID, http.MethodGet, "/api/runtimes/"+runtimeID+"/local-skills/import/"+importReq.ID, nil),
|
|
"runtimeId", runtimeID,
|
|
"requestId", importReq.ID,
|
|
)
|
|
testHandler.GetLocalSkillImportRequest(w, pollReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetLocalSkillImportRequest: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var completed RuntimeLocalSkillImportRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&completed); err != nil {
|
|
t.Fatalf("decode poll response: %v", err)
|
|
}
|
|
if completed.Status != RuntimeLocalSkillCompleted {
|
|
t.Fatalf("expected completed status, got %s", completed.Status)
|
|
}
|
|
if completed.Skill == nil {
|
|
t.Fatal("expected imported skill")
|
|
}
|
|
if completed.Skill.Name != "Imported Review Helper" {
|
|
t.Fatalf("imported name = %q", completed.Skill.Name)
|
|
}
|
|
if completed.Skill.Description != "Imported description" {
|
|
t.Fatalf("imported description = %q", completed.Skill.Description)
|
|
}
|
|
if len(completed.Skill.Files) != 1 {
|
|
t.Fatalf("expected poll response to include 1 imported file, got %d", len(completed.Skill.Files))
|
|
}
|
|
if completed.Skill.Files[0].Path != "templates/check.md" || completed.Skill.Files[0].Content != "body" {
|
|
t.Fatalf("unexpected imported file in poll response: %+v", completed.Skill.Files[0])
|
|
}
|
|
if got := countSkillFiles(t, completed.Skill.ID); got != 1 {
|
|
t.Fatalf("expected 1 imported file, got %d", got)
|
|
}
|
|
}
|
|
|
|
func TestBatchImportViaHeartbeat(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
|
|
// Create 5 import requests.
|
|
skillKeys := []string{"skill-a", "skill-b", "skill-c", "skill-d", "skill-e"}
|
|
importIDs := make([]string, 0, len(skillKeys))
|
|
for _, key := range skillKeys {
|
|
w := httptest.NewRecorder()
|
|
req := withURLParams(
|
|
newRequestAsUser(testUserID, http.MethodPost, "/api/runtimes/"+runtimeID+"/local-skills/import", map[string]any{
|
|
"skill_key": key,
|
|
}),
|
|
"runtimeId", runtimeID,
|
|
)
|
|
testHandler.InitiateImportLocalSkill(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("InitiateImportLocalSkill(%s): expected 200, got %d: %s", key, w.Code, w.Body.String())
|
|
}
|
|
var importReq RuntimeLocalSkillImportRequest
|
|
if err := json.NewDecoder(w.Body).Decode(&importReq); err != nil {
|
|
t.Fatalf("decode import request: %v", err)
|
|
}
|
|
importIDs = append(importIDs, importReq.ID)
|
|
}
|
|
|
|
// Single heartbeat should return all 5 via the plural field.
|
|
w := httptest.NewRecorder()
|
|
heartbeatReq := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
"supports_batch_import": true,
|
|
}, testWorkspaceID, "runtime-local-skills-daemon")
|
|
testHandler.DaemonHeartbeat(w, heartbeatReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonHeartbeat: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var heartbeatResp map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&heartbeatResp); err != nil {
|
|
t.Fatalf("decode heartbeat response: %v", err)
|
|
}
|
|
|
|
// Singular field (backwards compat) should contain the first item.
|
|
singular, ok := heartbeatResp["pending_local_skill_import"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected pending_local_skill_import, got %v", heartbeatResp)
|
|
}
|
|
if singular["id"] != importIDs[0] {
|
|
t.Fatalf("singular id = %v, want %s", singular["id"], importIDs[0])
|
|
}
|
|
|
|
// Plural field should contain all 5.
|
|
pluralRaw, ok := heartbeatResp["pending_local_skill_imports"].([]any)
|
|
if !ok {
|
|
t.Fatalf("expected pending_local_skill_imports array, got %T", heartbeatResp["pending_local_skill_imports"])
|
|
}
|
|
if len(pluralRaw) != len(skillKeys) {
|
|
t.Fatalf("expected %d pending imports, got %d", len(skillKeys), len(pluralRaw))
|
|
}
|
|
|
|
// Verify IDs match.
|
|
gotIDs := make(map[string]bool)
|
|
for _, item := range pluralRaw {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected map, got %T", item)
|
|
}
|
|
gotIDs[m["id"].(string)] = true
|
|
}
|
|
for _, id := range importIDs {
|
|
if !gotIDs[id] {
|
|
t.Fatalf("missing import ID %s in plural field", id)
|
|
}
|
|
}
|
|
|
|
// Second heartbeat should return nothing (all were claimed).
|
|
w = httptest.NewRecorder()
|
|
heartbeatReq2 := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
"supports_batch_import": true,
|
|
}, testWorkspaceID, "runtime-local-skills-daemon")
|
|
testHandler.DaemonHeartbeat(w, heartbeatReq2)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonHeartbeat: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var heartbeatResp2 map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&heartbeatResp2); err != nil {
|
|
t.Fatalf("decode heartbeat response: %v", err)
|
|
}
|
|
if _, ok := heartbeatResp2["pending_local_skill_import"]; ok {
|
|
t.Fatalf("second heartbeat should have no pending imports, got %v", heartbeatResp2)
|
|
}
|
|
if _, ok := heartbeatResp2["pending_local_skill_imports"]; ok {
|
|
t.Fatalf("second heartbeat should have no pending imports plural, got %v", heartbeatResp2)
|
|
}
|
|
}
|
|
|
|
func TestReportLocalSkillImportResult_IgnoresTimedOutRequests(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
ctx := context.Background()
|
|
importReq, err := testHandler.LocalSkillImportStore.Create(ctx, LocalSkillImportRequestInput{
|
|
RuntimeID: runtimeID,
|
|
CreatorID: testUserID,
|
|
SkillKey: "review-helper",
|
|
Name: cleanOptionalString(ptr("Timed Out Import")),
|
|
Description: cleanOptionalString(ptr("Should not be created")),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create import request: %v", err)
|
|
}
|
|
importReq.Status = RuntimeLocalSkillRunning
|
|
startedAt := time.Now().Add(-61 * time.Second)
|
|
importReq.RunStartedAt = &startedAt
|
|
|
|
timedOut, err := testHandler.LocalSkillImportStore.Get(ctx, importReq.ID)
|
|
if err != nil {
|
|
t.Fatalf("get import request: %v", err)
|
|
}
|
|
if timedOut == nil || timedOut.Status != RuntimeLocalSkillTimeout {
|
|
t.Fatalf("expected timed out request, got %#v", timedOut)
|
|
}
|
|
|
|
beforeCount := countSkillsByName(t, "Timed Out Import")
|
|
|
|
w := httptest.NewRecorder()
|
|
reportReq := withURLParams(
|
|
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+importReq.ID+"/result", map[string]any{
|
|
"status": "completed",
|
|
"skill": map[string]any{
|
|
"name": "Original Review Helper",
|
|
"description": "Original description",
|
|
"content": "# Review Helper",
|
|
"source_path": "~/.claude/skills/review-helper",
|
|
"provider": "claude",
|
|
},
|
|
}, testWorkspaceID, "runtime-local-skills-daemon"),
|
|
"runtimeId", runtimeID,
|
|
"requestId", importReq.ID,
|
|
)
|
|
testHandler.ReportLocalSkillImportResult(w, reportReq)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ReportLocalSkillImportResult: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
afterCount := countSkillsByName(t, "Timed Out Import")
|
|
if afterCount != beforeCount {
|
|
t.Fatalf("expected timed out report to be ignored, count before=%d after=%d", beforeCount, afterCount)
|
|
}
|
|
}
|
|
|
|
func TestReportLocalSkillImportResult_RejectsCrossWorkspaceDaemonToken(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
importReq, err := testHandler.LocalSkillImportStore.Create(context.Background(), LocalSkillImportRequestInput{
|
|
RuntimeID: runtimeID,
|
|
CreatorID: testUserID,
|
|
SkillKey: "review-helper",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create import request: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
reportReq := withURLParams(
|
|
newDaemonTokenRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/local-skills/import/"+importReq.ID+"/result", map[string]any{
|
|
"status": "failed",
|
|
"error": "forbidden",
|
|
}, "00000000-0000-0000-0000-000000000000", "attacker-daemon"),
|
|
"runtimeId", runtimeID,
|
|
"requestId", importReq.ID,
|
|
)
|
|
testHandler.ReportLocalSkillImportResult(w, reportReq)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCleanOptionalString(t *testing.T) {
|
|
if got := cleanOptionalString(nil); got != nil {
|
|
t.Fatalf("expected nil, got %q", *got)
|
|
}
|
|
|
|
raw := " "
|
|
if got := cleanOptionalString(&raw); got != nil {
|
|
t.Fatalf("expected nil for whitespace-only value, got %q", *got)
|
|
}
|
|
|
|
value := " Review Helper "
|
|
got := cleanOptionalString(&value)
|
|
if got == nil || *got != "Review Helper" {
|
|
t.Fatalf("expected trimmed value, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func ptr[T any](value T) *T {
|
|
return &value
|
|
}
|