mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
438 lines
15 KiB
Go
438 lines
15 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestHandleSkillDependenciesStatus_ReturnsStructuredMissingBySource(t *testing.T) {
|
|
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
|
skillDir := filepath.Join(root, "skills-store", "dep-skill", "1")
|
|
skillID := skillStore.seedCustomSkill("dep-skill", skillDir, "archived", []string{"ffmpeg", "pip:requests"})
|
|
|
|
prevScan := scanSkillDeps
|
|
prevCheck := checkSkillDeps
|
|
prevGitHubInstalled := githubSkillDependencyInstalled
|
|
scanSkillDeps = func(string) *skills.SkillManifest {
|
|
return &skills.SkillManifest{
|
|
Requires: []string{"ffmpeg"},
|
|
RequiresPython: []string{"requests"},
|
|
RequiresNode: []string{"tsx"},
|
|
Explicit: []string{"github:cli/cli@v2.40.0"},
|
|
}
|
|
}
|
|
checkSkillDeps = func(*skills.SkillManifest) (bool, []string) {
|
|
return false, []string{"ffmpeg", "pip:requests"}
|
|
}
|
|
githubSkillDependencyInstalled = func(string) bool { return false }
|
|
t.Cleanup(func() {
|
|
scanSkillDeps = prevScan
|
|
checkSkillDeps = prevCheck
|
|
githubSkillDependencyInstalled = prevGitHubInstalled
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody).WithContext(ctx)
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handleSkillDependenciesStatus(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
OK bool `json:"ok"`
|
|
MissingCount int `json:"missing_count"`
|
|
Dependencies []struct {
|
|
Source string `json:"source"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
} `json:"dependencies"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.OK {
|
|
t.Fatal("ok = true, want false")
|
|
}
|
|
if resp.MissingCount != 3 {
|
|
t.Fatalf("missing_count = %d, want 3", resp.MissingCount)
|
|
}
|
|
got := map[string]string{}
|
|
for _, dep := range resp.Dependencies {
|
|
got[dep.Source+":"+dep.Name] = dep.Status
|
|
}
|
|
want := map[string]string{
|
|
"system:ffmpeg": "missing",
|
|
"pip:requests": "missing",
|
|
"npm:tsx": "installed",
|
|
"github:cli/cli@v2.40.0": "missing",
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("dependencies = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSkillDependencyRoutesRequireAdmin(t *testing.T) {
|
|
handler, skillStore, _, root := newTestUploadHandler(t)
|
|
skillID := skillStore.seedCustomSkill("dep-skill", filepath.Join(root, "skills-store", "dep-skill", "1"), "active", nil)
|
|
tenantID := uuid.New()
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey("read-token"): {ID: uuid.New(), Scopes: []string{"operator.read"}},
|
|
crypto.HashAPIKey("tenant-admin-token"): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.admin"},
|
|
TenantID: tenantID,
|
|
OwnerID: "tenant-admin",
|
|
},
|
|
})
|
|
|
|
prevScan := scanSkillDeps
|
|
scanCalled := false
|
|
scanSkillDeps = func(string) *skills.SkillManifest {
|
|
scanCalled = true
|
|
return &skills.SkillManifest{}
|
|
}
|
|
t.Cleanup(func() { scanSkillDeps = prevScan })
|
|
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody)
|
|
req.Header.Set("Authorization", "Bearer read-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
}
|
|
if scanCalled {
|
|
t.Fatal("dependency scan ran for non-admin caller")
|
|
}
|
|
|
|
scanCalled = false
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/dependencies", http.NoBody)
|
|
req.Header.Set("Authorization", "Bearer tenant-admin-token")
|
|
w = httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("tenant admin status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
}
|
|
if scanCalled {
|
|
t.Fatal("dependency scan ran for non-master tenant admin")
|
|
}
|
|
}
|
|
|
|
func TestHandleSkillDependenciesInstall_SplitsGitHubDepsToSingleInstaller(t *testing.T) {
|
|
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
|
skillDir := filepath.Join(root, "skills-store", "dep-skill", "1")
|
|
skillID := skillStore.seedCustomSkill("dep-skill", skillDir, "archived", []string{"pip:requests", "github:cli/cli@v2.40.0"})
|
|
|
|
prevScan := scanSkillDeps
|
|
prevCheck := checkSkillDeps
|
|
prevInstallManaged := installManagedDeps
|
|
prevInstallSingle := installSingleDep
|
|
prevGitHubInstalled := githubSkillDependencyInstalled
|
|
githubInstalled := false
|
|
scanSkillDeps = func(string) *skills.SkillManifest {
|
|
return &skills.SkillManifest{
|
|
RequiresPython: []string{"requests"},
|
|
Explicit: []string{"github:cli/cli@v2.40.0"},
|
|
}
|
|
}
|
|
checkSkillDeps = func(*skills.SkillManifest) (bool, []string) {
|
|
if githubInstalled {
|
|
return true, nil
|
|
}
|
|
return false, []string{"pip:requests"}
|
|
}
|
|
githubSkillDependencyInstalled = func(raw string) bool {
|
|
if raw != "github:cli/cli@v2.40.0" {
|
|
t.Fatalf("github status checked for %q", raw)
|
|
}
|
|
return githubInstalled
|
|
}
|
|
installManagedDeps = func(_ context.Context, _ *skills.SkillManifest, missing []string) (*skills.InstallResult, error) {
|
|
if !reflect.DeepEqual(missing, []string{"pip:requests"}) {
|
|
t.Fatalf("managed missing = %#v, want pip only", missing)
|
|
}
|
|
return &skills.InstallResult{Pip: []string{"requests"}}, nil
|
|
}
|
|
installSingleDep = func(_ context.Context, dep string) (bool, string) {
|
|
if dep != "github:cli/cli@v2.40.0" {
|
|
t.Fatalf("single dep = %q, want github spec", dep)
|
|
}
|
|
githubInstalled = true
|
|
return true, ""
|
|
}
|
|
t.Cleanup(func() {
|
|
scanSkillDeps = prevScan
|
|
checkSkillDeps = prevCheck
|
|
installManagedDeps = prevInstallManaged
|
|
installSingleDep = prevInstallSingle
|
|
githubSkillDependencyInstalled = prevGitHubInstalled
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/dependencies/install", http.NoBody).WithContext(ctx)
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handleSkillDependenciesInstall(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Result skills.InstallResult `json:"result"`
|
|
Deps struct {
|
|
OK bool `json:"ok"`
|
|
MissingCount int `json:"missing_count"`
|
|
} `json:"dependencies"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(resp.Result.Pip, []string{"requests"}) || !reflect.DeepEqual(resp.Result.GitHub, []string{"cli/cli@v2.40.0"}) {
|
|
t.Fatalf("install result = %+v", resp.Result)
|
|
}
|
|
if !resp.Deps.OK || resp.Deps.MissingCount != 0 {
|
|
t.Fatalf("dependencies = %+v", resp.Deps)
|
|
}
|
|
}
|
|
|
|
func TestHandleSkillDependenciesInstall_RejectsNonMasterTenant(t *testing.T) {
|
|
handler, skillStore, _, root := newTestUploadHandler(t)
|
|
tenantID := uuid.New()
|
|
ctx := store.WithTenantID(context.Background(), tenantID)
|
|
skillDir := filepath.Join(root, "tenants", tenantID.String(), "skills-store", "dep-skill", "1")
|
|
skillID := skillStore.seedCustomSkillForTenant(tenantID, "dep-skill", skillDir, "archived", []string{"pip:requests"})
|
|
|
|
installCalled := false
|
|
prevInstall := installManagedDeps
|
|
installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
|
installCalled = true
|
|
return &skills.InstallResult{}, nil
|
|
}
|
|
t.Cleanup(func() { installManagedDeps = prevInstall })
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/dependencies/install", http.NoBody).WithContext(ctx)
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handleSkillDependenciesInstall(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
}
|
|
if installCalled {
|
|
t.Fatal("installManagedDeps was called for non-master tenant")
|
|
}
|
|
}
|
|
|
|
func TestSkillGrantUserRouteRejectsNonTenantMember(t *testing.T) {
|
|
handler, skillStore, _, root := newTestUploadHandler(t)
|
|
tenantID := uuid.New()
|
|
skillID := skillStore.seedCustomSkillForTenant(tenantID, "access-skill", filepath.Join(root, "tenants", tenantID.String(), "skills-store", "access-skill", "1"), "active", nil)
|
|
tenants := newMockTenantStore()
|
|
tenants.addTenant(tenantID, "tenant-a")
|
|
tenants.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
|
|
handler.tenantStore = tenants
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey("admin-token"): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.admin"},
|
|
TenantID: tenantID,
|
|
OwnerID: "admin-user",
|
|
},
|
|
})
|
|
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/grants/users", strings.NewReader(`{"user_id":"outside-user"}`))
|
|
req.Header.Set("Authorization", "Bearer admin-token")
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
}
|
|
if len(skillStore.userGrantCalls) != 0 {
|
|
t.Fatalf("user grant calls = %+v, want none", skillStore.userGrantCalls)
|
|
}
|
|
}
|
|
|
|
func TestHandleSkillAccessGetListsAgentAndUserGrants(t *testing.T) {
|
|
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
|
skillID := skillStore.seedCustomSkill("access-skill", filepath.Join(root, "skills-store", "access-skill", "1"), "active", nil)
|
|
agentID := uuid.New()
|
|
skillStore.agentGrants[skillID] = []store.SkillAgentGrantInfo{{
|
|
AgentID: agentID,
|
|
PinnedVersion: 3,
|
|
GrantedBy: "owner-user",
|
|
CanManage: true,
|
|
}}
|
|
skillStore.userGrants[skillID] = []store.SkillUserGrantInfo{{
|
|
UserID: "target-user",
|
|
GrantedBy: "owner-user",
|
|
}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/access", http.NoBody).WithContext(ctx)
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handleGetSkillAccess(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Visibility string `json:"visibility"`
|
|
AgentGrants []store.SkillAgentGrantInfo `json:"agent_grants"`
|
|
UserGrants []store.SkillUserGrantInfo `json:"user_grants"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if len(resp.AgentGrants) != 1 || !resp.AgentGrants[0].CanManage || resp.AgentGrants[0].PinnedVersion != 3 {
|
|
t.Fatalf("agent grants = %+v", resp.AgentGrants)
|
|
}
|
|
if len(resp.UserGrants) != 1 || resp.UserGrants[0].UserID != "target-user" {
|
|
t.Fatalf("user grants = %+v", resp.UserGrants)
|
|
}
|
|
}
|
|
|
|
func TestHandleSkillEffectiveAccessReportsAgentGrant(t *testing.T) {
|
|
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
|
skillID := skillStore.seedCustomSkill("effective-skill", filepath.Join(root, "skills-store", "effective-skill", "1"), "active", nil)
|
|
agentID := uuid.New()
|
|
tenants := newMockTenantStore()
|
|
tenants.addTenant(store.MasterTenantID, "master")
|
|
tenants.setUserRole(store.MasterTenantID, "target-user", store.TenantRoleMember)
|
|
handler.tenantStore = tenants
|
|
skillStore.skills[skillID] = store.SkillInfo{
|
|
ID: skillID.String(),
|
|
TenantID: store.MasterTenantID.String(),
|
|
Name: "Effective Skill",
|
|
Slug: "effective-skill",
|
|
Path: filepath.Join(root, "skills-store", "effective-skill", "1", "SKILL.md"),
|
|
BaseDir: filepath.Join(root, "skills-store", "effective-skill", "1"),
|
|
Visibility: "internal",
|
|
Status: "active",
|
|
Enabled: true,
|
|
}
|
|
skillStore.agentGrants[skillID] = []store.SkillAgentGrantInfo{{
|
|
AgentID: agentID,
|
|
PinnedVersion: 2,
|
|
GrantedBy: "owner-user",
|
|
CanManage: true,
|
|
}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/skills/"+skillID.String()+"/access/effective?agent_id="+agentID.String()+"&user_id=target-user", http.NoBody).WithContext(ctx)
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handleGetSkillEffectiveAccess(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Accessible bool `json:"accessible"`
|
|
Reason string `json:"reason"`
|
|
CanManage bool `json:"can_manage"`
|
|
PinnedVersion *int `json:"pinned_version"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !resp.Accessible || resp.Reason != "agent_grant" || !resp.CanManage || resp.PinnedVersion == nil || *resp.PinnedVersion != 2 {
|
|
t.Fatalf("effective access = %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveAccessTreatsAccessibleIndexAsSourceOfTruthForSystemSkills(t *testing.T) {
|
|
ctx := store.WithTenantID(context.Background(), uuid.New())
|
|
sk := store.SkillInfo{
|
|
ID: uuid.NewString(),
|
|
Name: "System Skill",
|
|
Slug: "system-skill",
|
|
Status: "active",
|
|
Enabled: true,
|
|
IsSystem: true,
|
|
}
|
|
resp := effectiveAccessForSkill(ctx, sk, effectiveAccessIndex{accessibleBySlug: map[string]bool{}}, "target-user")
|
|
if resp.Accessible || resp.Reason != "none" {
|
|
t.Fatalf("effective access = %+v, want inaccessible none", resp)
|
|
}
|
|
resp = effectiveAccessForSkill(ctx, sk, effectiveAccessIndex{accessibleBySlug: map[string]bool{"system-skill": true}}, "target-user")
|
|
if !resp.Accessible || resp.Reason != "system" {
|
|
t.Fatalf("effective access = %+v, want system access", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandlePatchSkillAccessRejectsNonMasterSystemSkillUpdate(t *testing.T) {
|
|
handler, skillStore, _, root := newTestUploadHandler(t)
|
|
tenantID := uuid.New()
|
|
skillID := skillStore.seedSystemSkill("system-skill", filepath.Join(root, "bundled", "system-skill"))
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey("tenant-admin-token"): {
|
|
ID: uuid.New(),
|
|
Scopes: []string{"operator.admin"},
|
|
TenantID: tenantID,
|
|
OwnerID: "tenant-admin",
|
|
},
|
|
})
|
|
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/skills/"+skillID.String()+"/access", strings.NewReader(`{"mode":"public"}`))
|
|
req.Header.Set("Authorization", "Bearer tenant-admin-token")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
mux.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
|
|
}
|
|
if updates := skillStore.lastUpdates[skillID]; updates != nil {
|
|
t.Fatalf("system skill updated by non-master tenant: %+v", updates)
|
|
}
|
|
}
|
|
|
|
func TestHandlePatchSkillAccessAcceptsModeAlias(t *testing.T) {
|
|
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
|
skillID := skillStore.seedCustomSkill("access-skill", filepath.Join(root, "skills-store", "access-skill", "1"), "active", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/skills/"+skillID.String()+"/access", bytes.NewBufferString(`{"mode":"PUBLIC"}`)).WithContext(ctx)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.SetPathValue("id", skillID.String())
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.handlePatchSkillAccess(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
|
}
|
|
info, _ := skillStore.GetSkillByID(ctx, skillID)
|
|
if info.Visibility != "public" {
|
|
t.Fatalf("visibility = %q, want public", info.Visibility)
|
|
}
|
|
}
|