Files
goclaw/tests/integration/v3_skills_store_test.go
T

561 lines
15 KiB
Go

//go:build integration
package integration
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
func newSkillStore(t *testing.T) *pg.PGSkillStore {
t.Helper()
db := testDB(t)
pg.InitSqlx(db)
return pg.NewPGSkillStore(db, t.TempDir())
}
// seedSkill inserts a custom skill via CreateSkillManaged and returns its UUID.
func seedSkill(t *testing.T, s *pg.PGSkillStore, ctx context.Context, slug, name string) uuid.UUID {
t.Helper()
desc := "test skill: " + name
id, err := s.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: name,
Slug: slug,
Description: &desc,
OwnerID: "test-owner",
Visibility: "private",
Status: "active",
Version: 1,
FilePath: "/tmp/skills/" + slug + "/1",
FileSize: 100,
})
if err != nil {
t.Fatalf("seedSkill(%s): %v", slug, err)
}
return id
}
func TestStoreSkill_CreateAndGet(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
desc := "A tool for testing"
id, err := s.CreateSkillManaged(ctx, store.SkillCreateParams{
Name: "Test Skill",
Slug: "test-skill-" + tenantID.String()[:8],
Description: &desc,
OwnerID: "test-owner",
Visibility: "private",
Status: "active",
Version: 1,
FilePath: "/tmp/skills/test-skill/1",
FileSize: 256,
Frontmatter: map[string]string{"author": "tester"},
})
if err != nil {
t.Fatalf("CreateSkillManaged: %v", err)
}
if id == uuid.Nil {
t.Fatal("expected non-nil skill ID")
}
// GetSkillByID
got, ok := s.GetSkillByID(ctx, id)
if !ok {
t.Fatal("GetSkillByID returned false")
}
if got.Name != "Test Skill" {
t.Errorf("Name = %q, want %q", got.Name, "Test Skill")
}
if got.Description != "A tool for testing" {
t.Errorf("Description = %q, want %q", got.Description, "A tool for testing")
}
if got.Visibility != "private" {
t.Errorf("Visibility = %q, want %q", got.Visibility, "private")
}
if got.Status != "active" {
t.Errorf("Status = %q, want %q", got.Status, "active")
}
// GetSkill (by slug)
slug := "test-skill-" + tenantID.String()[:8]
got2, ok := s.GetSkill(ctx, slug)
if !ok {
t.Fatal("GetSkill by slug returned false")
}
if got2.ID != id.String() {
t.Errorf("GetSkill ID = %q, want %q", got2.ID, id.String())
}
// GetSkillOwnerID
ownerID, ok := s.GetSkillOwnerID(ctx, id)
if !ok {
t.Fatal("GetSkillOwnerID returned false")
}
if ownerID != "test-owner" {
t.Errorf("OwnerID = %q, want %q", ownerID, "test-owner")
}
// GetSkillOwnerIDBySlug
ownerID2, ok := s.GetSkillOwnerIDBySlug(ctx, slug)
if !ok {
t.Fatal("GetSkillOwnerIDBySlug returned false")
}
if ownerID2 != "test-owner" {
t.Errorf("OwnerIDBySlug = %q, want %q", ownerID2, "test-owner")
}
}
func TestStoreSkill_Update(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
slug := "upd-skill-" + tenantID.String()[:8]
id := seedSkill(t, s, ctx, slug, "Update Me")
// Update description and visibility
err := s.UpdateSkill(ctx, id, map[string]any{
"description": "updated description",
"visibility": "public",
})
if err != nil {
t.Fatalf("UpdateSkill: %v", err)
}
got, ok := s.GetSkillByID(ctx, id)
if !ok {
t.Fatal("GetSkillByID after update returned false")
}
if got.Description != "updated description" {
t.Errorf("Description = %q, want %q", got.Description, "updated description")
}
if got.Visibility != "public" {
t.Errorf("Visibility = %q, want %q", got.Visibility, "public")
}
}
func TestStoreSkill_Delete(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
slug := "del-skill-" + tenantID.String()[:8]
id := seedSkill(t, s, ctx, slug, "Delete Me")
// Verify it exists in list
list := s.ListSkills(ctx)
found := false
for _, sk := range list {
if sk.Slug == slug {
found = true
break
}
}
if !found {
t.Fatal("skill not found in ListSkills before delete")
}
// Delete (soft-delete)
if err := s.DeleteSkill(ctx, id); err != nil {
t.Fatalf("DeleteSkill: %v", err)
}
// Verify soft-deleted: GetSkillByID still works (returns any status)
got, ok := s.GetSkillByID(ctx, id)
if !ok {
t.Fatal("GetSkillByID after delete returned false")
}
if got.Status != "deleted" {
t.Errorf("Status = %q, want %q", got.Status, "deleted")
}
// Bump version to invalidate cache, then verify not in ListSkills
s.BumpVersion()
list2 := s.ListSkills(ctx)
for _, sk := range list2 {
if sk.Slug == slug {
t.Error("deleted skill still appears in ListSkills")
}
}
}
func TestStoreSkill_ListSkills(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
slug1 := "list-a-" + tenantID.String()[:8]
slug2 := "list-b-" + tenantID.String()[:8]
seedSkill(t, s, ctx, slug1, "List A")
seedSkill(t, s, ctx, slug2, "List B")
// Bump version to invalidate cache
s.BumpVersion()
list := s.ListSkills(ctx)
found1, found2 := false, false
for _, sk := range list {
if sk.Slug == slug1 {
found1 = true
}
if sk.Slug == slug2 {
found2 = true
}
}
if !found1 {
t.Errorf("skill %q not found in ListSkills", slug1)
}
if !found2 {
t.Errorf("skill %q not found in ListSkills", slug2)
}
// ListAllSkills should also include them
all := s.ListAllSkills(ctx)
found1 = false
for _, sk := range all {
if sk.Slug == slug1 {
found1 = true
break
}
}
if !found1 {
t.Errorf("skill %q not found in ListAllSkills", slug1)
}
// Toggle disabled — should disappear from ListAllSkills (which filters enabled=true)
id := seedSkill(t, s, ctx, "list-toggle-"+tenantID.String()[:8], "Toggle Me")
if err := s.ToggleSkill(ctx, id, false); err != nil {
t.Fatalf("ToggleSkill: %v", err)
}
s.BumpVersion()
allAfter := s.ListAllSkills(ctx)
for _, sk := range allAfter {
if sk.Slug == "list-toggle-"+tenantID.String()[:8] {
t.Error("disabled skill still in ListAllSkills")
}
}
}
func TestStoreSkill_GrantToAgent(t *testing.T) {
db := testDB(t)
tenantID, agentID := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
slug := "grant-skill-" + tenantID.String()[:8]
skillID := seedSkill(t, s, ctx, slug, "Grant Skill")
// Grant to agent
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner"); err != nil {
t.Fatalf("GrantToAgent: %v", err)
}
// Verify via ListWithGrantStatus
grantList, err := s.ListWithGrantStatus(ctx, agentID)
if err != nil {
t.Fatalf("ListWithGrantStatus: %v", err)
}
found := false
for _, g := range grantList {
if g.ID == skillID {
if !g.Granted {
t.Error("expected Granted=true for granted skill")
}
if g.CanManage {
t.Error("expected CanManage=false by default")
}
found = true
break
}
}
if !found {
t.Error("granted skill not found in ListWithGrantStatus")
}
// Verify via ListAccessible
accessible, err := s.ListAccessible(ctx, agentID, "test-owner")
if err != nil {
t.Fatalf("ListAccessible: %v", err)
}
foundAccessible := false
for _, sk := range accessible {
if sk.Slug == slug {
foundAccessible = true
break
}
}
if !foundAccessible {
t.Error("granted skill not found in ListAccessible")
}
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner", true); err != nil {
t.Fatalf("GrantToAgent can_manage: %v", err)
}
canManage, err := s.AgentCanManageSkill(ctx, skillID, agentID)
if err != nil {
t.Fatalf("AgentCanManageSkill: %v", err)
}
if !canManage {
t.Error("expected AgentCanManageSkill=true after manage grant")
}
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner"); err != nil {
t.Fatalf("GrantToAgent preserve can_manage: %v", err)
}
canManage, err = s.AgentCanManageSkill(ctx, skillID, agentID)
if err != nil {
t.Fatalf("AgentCanManageSkill after preserve grant: %v", err)
}
if !canManage {
t.Error("expected omitted can_manage grant update to preserve existing manage permission")
}
if err := s.GrantToAgent(ctx, skillID, agentID, 1, "test-owner", false); err != nil {
t.Fatalf("GrantToAgent can_manage false: %v", err)
}
canManage, err = s.AgentCanManageSkill(ctx, skillID, agentID)
if err != nil {
t.Fatalf("AgentCanManageSkill after false grant: %v", err)
}
if canManage {
t.Error("expected explicit can_manage=false to revoke manage permission")
}
// Revoke
if err := s.RevokeFromAgent(ctx, skillID, agentID); err != nil {
t.Fatalf("RevokeFromAgent: %v", err)
}
// Verify revoked
grantList2, err := s.ListWithGrantStatus(ctx, agentID)
if err != nil {
t.Fatalf("ListWithGrantStatus after revoke: %v", err)
}
for _, g := range grantList2 {
if g.ID == skillID && g.Granted {
t.Error("expected Granted=false after revoke")
}
}
}
func TestStoreSkill_UserGrantListPromotesAndDemotesVisibility(t *testing.T) {
db := testDB(t)
tenantID, _ := seedTenantAgent(t, db)
ctx := tenantCtx(tenantID)
s := newSkillStore(t)
skillID := seedSkill(t, s, ctx, "user-grant-skill-"+tenantID.String()[:8], "User Grant Skill")
if err := s.GrantToUser(ctx, skillID, "granted-user", "test-owner"); err != nil {
t.Fatalf("GrantToUser: %v", err)
}
grants, err := s.ListUserGrantsForSkill(ctx, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "granted-user" || grants[0].GrantedBy != "test-owner" {
t.Fatalf("user grants = %+v", grants)
}
got, ok := s.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned false")
}
if got.Visibility != "internal" {
t.Fatalf("visibility after user grant = %q, want internal", got.Visibility)
}
if err := s.RevokeFromUser(ctx, skillID, "granted-user"); err != nil {
t.Fatalf("RevokeFromUser: %v", err)
}
got, ok = s.GetSkillByID(ctx, skillID)
if !ok {
t.Fatal("GetSkillByID returned false after revoke")
}
if got.Visibility != "private" {
t.Fatalf("visibility after last user grant revoke = %q, want private", got.Visibility)
}
}
func TestStoreSkill_UserGrantsAreTenantScopedForSystemSkill(t *testing.T) {
db := testDB(t)
tenantA, _ := seedTenantAgent(t, db)
tenantB, _ := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
s := newSkillStore(t)
skillID := uuid.New()
slug := "system-user-grant-" + skillID.String()[:8]
if _, err := db.Exec(
`INSERT INTO skills (id, name, slug, owner_id, visibility, version, status, file_path, is_system, tenant_id)
VALUES ($1, 'System User Grant Skill', $2, 'system', 'private', 1, 'active', $3, true, $4)`,
skillID, slug, "/tmp/skills/system-user-grant/1", store.MasterTenantID,
); err != nil {
t.Fatalf("insert system skill: %v", err)
}
if err := s.GrantToUser(ctxA, skillID, "same-user", "tenant-a-admin"); err != nil {
t.Fatalf("GrantToUser tenant A: %v", err)
}
if err := s.GrantToUser(ctxB, skillID, "same-user", "tenant-b-admin"); err != nil {
t.Fatalf("GrantToUser tenant B: %v", err)
}
grantsA, err := s.ListUserGrantsForSkill(ctxA, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant A: %v", err)
}
grantsB, err := s.ListUserGrantsForSkill(ctxB, skillID)
if err != nil {
t.Fatalf("ListUserGrantsForSkill tenant B: %v", err)
}
if len(grantsA) != 1 || grantsA[0].GrantedBy != "tenant-a-admin" {
t.Fatalf("tenant A grants = %+v", grantsA)
}
if len(grantsB) != 1 || grantsB[0].GrantedBy != "tenant-b-admin" {
t.Fatalf("tenant B grants = %+v", grantsB)
}
}
func TestStoreSkill_GrantToAgentRejectsCrossTenantSkill(t *testing.T) {
db := testDB(t)
tenantA, agentA := seedTenantAgent(t, db)
tenantB, _ := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
s := newSkillStore(t)
skillB := seedSkill(t, s, ctxB, "grant-cross-tenant-"+tenantB.String()[:8], "Tenant B Skill")
if err := s.GrantToAgent(ctxA, skillB, agentA, 1, "test-owner", true); err == nil {
t.Fatal("GrantToAgent allowed tenant A to grant tenant B skill")
}
grants, err := s.ListAgentGrantsForSkill(ctxB, skillB)
if err != nil {
t.Fatalf("ListAgentGrantsForSkill: %v", err)
}
if len(grants) != 0 {
t.Fatalf("cross-tenant grant was inserted: %+v", grants)
}
got, ok := s.GetSkillByID(ctxB, skillB)
if !ok {
t.Fatal("GetSkillByID for tenant B skill returned false")
}
if got.Visibility != "private" {
t.Fatalf("cross-tenant grant changed visibility to %q, want private", got.Visibility)
}
}
func TestStoreSkill_RevokeFromAgentDoesNotDemoteCrossTenantSkill(t *testing.T) {
db := testDB(t)
tenantA, agentA := seedTenantAgent(t, db)
tenantB, _ := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
s := newSkillStore(t)
skillB := seedSkill(t, s, ctxB, "revoke-cross-tenant-"+tenantB.String()[:8], "Tenant B Skill")
if err := s.UpdateSkill(ctxB, skillB, map[string]any{"visibility": "internal"}); err != nil {
t.Fatalf("UpdateSkill: %v", err)
}
if err := s.RevokeFromAgent(ctxA, skillB, agentA); err == nil {
t.Fatal("RevokeFromAgent allowed tenant A to revoke tenant B skill")
}
got, ok := s.GetSkillByID(ctxB, skillB)
if !ok {
t.Fatal("GetSkillByID for tenant B skill returned false")
}
if got.Visibility != "internal" {
t.Fatalf("cross-tenant revoke demoted visibility to %q, want internal", got.Visibility)
}
}
func TestStoreSkill_ListWithGrantStatusIgnoresForeignTenantGrant(t *testing.T) {
db := testDB(t)
tenantA, _ := seedTenantAgent(t, db)
tenantB, agentB := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
s := newSkillStore(t)
skillID := uuid.New()
if _, err := db.Exec(
`INSERT INTO skills (id, name, slug, owner_id, visibility, version, status, file_path, is_system, tenant_id)
VALUES ($1, 'System Skill', $2, 'system', 'internal', 1, 'active', $3, true, $4)`,
skillID, "system-grant-status-"+skillID.String()[:8], "/tmp/skills/system-skill/1", store.MasterTenantID,
); err != nil {
t.Fatalf("insert system skill: %v", err)
}
if _, err := db.Exec(
`INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, can_manage, tenant_id)
VALUES ($1, $2, $3, 1, 'tenant-b-admin', true, $4)`,
uuid.New(), skillID, agentB, tenantB,
); err != nil {
t.Fatalf("insert foreign tenant grant: %v", err)
}
skills, err := s.ListWithGrantStatus(ctxA, agentB)
if err != nil {
t.Fatalf("ListWithGrantStatus error: %v", err)
}
for _, skill := range skills {
if skill.ID == skillID {
if skill.Granted || skill.CanManage {
t.Fatalf("foreign tenant grant leaked into tenant A status: granted=%v canManage=%v", skill.Granted, skill.CanManage)
}
return
}
}
t.Fatalf("system skill %s not returned for tenant A", skillID)
}
func TestStoreSkill_TenantIsolation(t *testing.T) {
db := testDB(t)
tenantA, _ := seedTenantAgent(t, db)
tenantB, _ := seedTenantAgent(t, db)
ctxA := tenantCtx(tenantA)
ctxB := tenantCtx(tenantB)
s := newSkillStore(t)
// Create skill in tenant A
slugA := "iso-skill-" + tenantA.String()[:8]
seedSkill(t, s, ctxA, slugA, "Tenant A Skill")
// Bump version to ensure fresh queries
s.BumpVersion()
// Tenant B should NOT see tenant A's skill
listB := s.ListSkills(ctxB)
for _, sk := range listB {
if sk.Slug == slugA {
t.Errorf("tenant B can see tenant A's skill %q — isolation broken", slugA)
}
}
// Tenant A should see it
listA := s.ListSkills(ctxA)
found := false
for _, sk := range listA {
if sk.Slug == slugA {
found = true
break
}
}
if !found {
t.Error("tenant A cannot see its own skill")
}
// GetSkill from tenant B context should fail
_, ok := s.GetSkill(ctxB, slugA)
if ok {
t.Error("GetSkill from tenant B returned true for tenant A's skill")
}
}