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": "review-helper", "name": "Review Helper", "description": "Review PRs", "source_path": "~/.claude/skills/review-helper", "provider": "claude", "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); 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/skills/review-helper" { t.Fatalf("source_path = %q", got.Skills[0].SourcePath) } 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") } } func TestInitiateListLocalSkills_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", nil), "runtimeId", runtimeID, ) testHandler.InitiateListLocalSkills(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 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 }