71c7672545
* feat(skill-hub): add Skill Hub catalog, publish flow, and lite materialize pipeline Introduce Skill Hub for browsing, importing, publishing, and installing skills, with lite instance package materialization and runtime sync support. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): remove token-governance hooks from Skill Hub PR Strip validateManagedRuntimeEnvironmentOverrides, network lock policy sync, and egress proxy audit wiring that belong to the upcoming token-usage work, so the Skill Hub branch compiles independently. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): update migration number in materialize docs Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): make RuntimeAgentClient test stub and hub tests compile-safe Add ResyncInstanceSkills to the runtime pool handler fake client, and harden skill hub payload helpers/tests against nil storage/instance repos so go test passes. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add Skill Hub hardening with session usage tracking and egress governance Unify Skill Hub runtime sync improvements with session-token observability, egress network policy, and admin/instance usage reporting for reopenable PR. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skill-hub): repair CI tests and nested skill install * fix(ci): restore release deployment configuration --------- Co-authored-by: heshengran <heshengran@ieisystem.com> Co-authored-by: Cursor <cursoragent@cursor.com>
958 lines
36 KiB
Go
958 lines
36 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"clawreef/internal/models"
|
|
)
|
|
|
|
func TestIsHubPublishableBlob(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
blob *models.SkillBlob
|
|
want bool
|
|
}{
|
|
{
|
|
name: "completed none risk with object key",
|
|
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "user/demo/hash.zip"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "completed low risk",
|
|
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskLow, ObjectKey: "key"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "medium risk blocked",
|
|
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskMedium, ObjectKey: "key"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "pending scan blocked",
|
|
blob: &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "missing object key blocked",
|
|
blob: &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: ""},
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isHubPublishableBlob(tc.blob); got != tc.want {
|
|
t.Fatalf("isHubPublishableBlob() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListMyHubSkillsExcludesDiscoveredSkills(t *testing.T) {
|
|
svc := &skillService{
|
|
repo: &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {ID: 1, UserID: 1, SkillKey: "dogfood", Name: "dogfood", Status: skillStatusActive, SourceType: skillSourceUploaded},
|
|
2: {ID: 2, UserID: 1, SkillKey: "software-development-spike", Name: "software-development/spike", Status: skillStatusActive, SourceType: skillSourceDiscovered},
|
|
},
|
|
},
|
|
}
|
|
items, err := svc.ListMyHubSkills(1)
|
|
if err != nil {
|
|
t.Fatalf("ListMyHubSkills() error = %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items = %d, want 1 uploaded skill only", len(items))
|
|
}
|
|
if items[0].SkillKey != "dogfood" {
|
|
t.Fatalf("SkillKey = %q, want dogfood", items[0].SkillKey)
|
|
}
|
|
}
|
|
|
|
func TestCanAttachSkillRules(t *testing.T) {
|
|
svc := &skillService{}
|
|
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPrivate, RiskLevel: skillRiskLow}
|
|
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Status: "active", Visibility: skillVisibilityPublic, RiskLevel: skillRiskLow}
|
|
ownInstance := &models.Instance{UserID: 1}
|
|
otherInstance := &models.Instance{UserID: 3}
|
|
|
|
if !svc.CanAttachSkill(1, "user", privateSkill, ownInstance) {
|
|
t.Fatal("owner should attach private skill to own instance")
|
|
}
|
|
if svc.CanAttachSkill(3, "user", privateSkill, otherInstance) {
|
|
t.Fatal("other user must not attach private skill")
|
|
}
|
|
if !svc.CanAttachSkill(3, "user", publicSkill, otherInstance) {
|
|
t.Fatal("user should attach public skill to own instance")
|
|
}
|
|
if svc.CanAttachSkill(3, "user", publicSkill, ownInstance) {
|
|
t.Fatal("user must not attach public skill to someone else's instance")
|
|
}
|
|
if !svc.CanAttachSkill(99, "admin", privateSkill, otherInstance) {
|
|
t.Fatal("admin should attach any skill to any instance")
|
|
}
|
|
}
|
|
|
|
func TestCanViewSkillRules(t *testing.T) {
|
|
svc := &skillService{}
|
|
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate}
|
|
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic}
|
|
|
|
if !svc.CanViewSkill(1, "user", privateSkill) {
|
|
t.Fatal("owner should view private skill")
|
|
}
|
|
if svc.CanViewSkill(3, "user", privateSkill) {
|
|
t.Fatal("other user must not view private skill")
|
|
}
|
|
if !svc.CanViewSkill(3, "user", publicSkill) {
|
|
t.Fatal("user should view public skill")
|
|
}
|
|
if !svc.CanViewSkill(99, "admin", privateSkill) {
|
|
t.Fatal("admin should view private skill")
|
|
}
|
|
}
|
|
|
|
func TestCanDownloadSkillRules(t *testing.T) {
|
|
svc := &skillService{}
|
|
privateSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate}
|
|
publicSkill := &models.Skill{UserID: 2, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic}
|
|
discoveredSkill := &models.Skill{UserID: 1, SourceType: skillSourceDiscovered, Visibility: skillVisibilityPublic}
|
|
|
|
if !svc.CanDownloadSkill(1, "user", privateSkill) {
|
|
t.Fatal("owner should download private uploaded skill")
|
|
}
|
|
if svc.CanDownloadSkill(3, "user", privateSkill) {
|
|
t.Fatal("other user must not download private skill")
|
|
}
|
|
if !svc.CanDownloadSkill(3, "user", publicSkill) {
|
|
t.Fatal("user should download public skill")
|
|
}
|
|
if svc.CanDownloadSkill(1, "user", discoveredSkill) {
|
|
t.Fatal("discovered skill is not user-managed and must not download")
|
|
}
|
|
}
|
|
|
|
func TestIsHubPublishableSkill(t *testing.T) {
|
|
svc := &skillService{}
|
|
blob := &models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "user/demo/hash.zip"}
|
|
activeSkill := &models.Skill{SourceType: skillSourceUploaded, Status: "active"}
|
|
inactiveSkill := &models.Skill{SourceType: skillSourceUploaded, Status: "inactive"}
|
|
|
|
if !svc.isHubPublishable(activeSkill, blob) {
|
|
t.Fatal("active uploaded skill with clean blob should be publishable")
|
|
}
|
|
if svc.isHubPublishable(inactiveSkill, blob) {
|
|
t.Fatal("inactive skill must not be publishable")
|
|
}
|
|
if svc.isHubPublishable(activeSkill, &models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key"}) {
|
|
t.Fatal("pending scan blob must block publish")
|
|
}
|
|
}
|
|
|
|
func TestValidateHubTagSelection(t *testing.T) {
|
|
svc := &skillService{repo: &hubTagRepoStub{
|
|
tags: map[int]*models.SkillHubTag{
|
|
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
|
2: {ID: 2, TagKey: "featured", Name: "Featured", AdminOnly: true},
|
|
},
|
|
}}
|
|
|
|
if err := svc.validateHubTagSelection("user", nil); err == nil || err.Error() != "skill_tags_required" {
|
|
t.Fatalf("empty tag list should require tags, got %v", err)
|
|
}
|
|
if err := svc.validateHubTagSelection("user", []int{2}); err == nil || err.Error() != "access denied" {
|
|
t.Fatalf("user must not select admin-only tag alone, got %v", err)
|
|
}
|
|
if err := svc.validateHubTagSelection("user", []int{1}); err != nil {
|
|
t.Fatalf("user with public tag should pass, got %v", err)
|
|
}
|
|
if err := svc.validateHubTagSelection("admin", []int{2}); err == nil || err.Error() != "skill_tags_required" {
|
|
t.Fatalf("admin-only tag alone should still require a public tag, got %v", err)
|
|
}
|
|
if err := svc.validateHubTagSelection("admin", []int{1, 2}); err != nil {
|
|
t.Fatalf("admin with mixed tags should pass, got %v", err)
|
|
}
|
|
}
|
|
|
|
func newPublishTestStub(blob *models.SkillBlob) (*skillService, *skillRepoStub) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: blob},
|
|
tags: map[int]*models.SkillHubTag{
|
|
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
|
},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
return &skillService{repo: stub}, stub
|
|
}
|
|
|
|
func TestDeleteSkillSoftPreservesInstanceSkills(t *testing.T) {
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic},
|
|
},
|
|
tagAssignments: map[int][]int{1: {1}},
|
|
instanceSkillsBySkillID: map[int][]models.InstanceSkill{
|
|
1: {{ID: 99, InstanceID: 2, SkillID: 1, Status: "active"}},
|
|
},
|
|
}
|
|
svc := &skillService{repo: stub}
|
|
if err := svc.DeleteSkill(1, "user", 1); err != nil {
|
|
t.Fatalf("DeleteSkill() error = %v", err)
|
|
}
|
|
if stub.hardDeleteCalled {
|
|
t.Fatal("DeleteSkill must not hard-delete skill row")
|
|
}
|
|
if stub.skills[1].Status != skillStatusDeleted {
|
|
t.Fatalf("expected soft-deleted status, got %q", stub.skills[1].Status)
|
|
}
|
|
if len(stub.tagAssignments[1]) != 0 {
|
|
t.Fatalf("expected tag assignments cleared, got %v", stub.tagAssignments[1])
|
|
}
|
|
if len(stub.instanceSkillsBySkillID[1]) != 1 {
|
|
t.Fatal("instance_skills records must remain after soft delete")
|
|
}
|
|
}
|
|
|
|
func TestCanViewSkillDeletedHidden(t *testing.T) {
|
|
svc := &skillService{}
|
|
deletedSkill := &models.Skill{UserID: 1, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, Status: skillStatusDeleted}
|
|
if svc.CanViewSkill(3, "user", deletedSkill) {
|
|
t.Fatal("deleted public skill must not be viewable")
|
|
}
|
|
}
|
|
|
|
func TestPublishToHubRejectsPendingScan(t *testing.T) {
|
|
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "pending", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
|
_, err := svc.PublishToHub(1, "user", 1, []int{1})
|
|
if err == nil || err.Error() != "skill_not_scanned" {
|
|
t.Fatalf("expected skill_not_scanned, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPublishToHubRejectsMediumRisk(t *testing.T) {
|
|
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskMedium, ObjectKey: "key.zip"})
|
|
_, err := svc.PublishToHub(1, "user", 1, []int{1})
|
|
if err == nil || err.Error() != "skill_risk_blocked" {
|
|
t.Fatalf("expected skill_risk_blocked, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPublishToHubRejectsEmptyTags(t *testing.T) {
|
|
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
|
_, err := svc.PublishToHub(1, "user", 1, nil)
|
|
if err == nil || err.Error() != "skill_tags_required" {
|
|
t.Fatalf("expected skill_tags_required, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPublishToHubAllowsAdminForOtherUsersSkill(t *testing.T) {
|
|
svc, _ := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
|
item, err := svc.PublishToHub(99, "admin", 1, []int{1})
|
|
if err != nil {
|
|
t.Fatalf("PublishToHub() error = %v", err)
|
|
}
|
|
if item == nil || !strings.EqualFold(item.Visibility, skillVisibilityPublic) {
|
|
t.Fatalf("expected admin publish to succeed, got %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestUnpublishAllowsAdminForOtherUsersSkill(t *testing.T) {
|
|
svc, stub := newPublishTestStub(&models.SkillBlob{ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"})
|
|
stub.skills[1].Visibility = skillVisibilityPublic
|
|
item, err := svc.UnpublishFromHub(99, "admin", 1)
|
|
if err != nil {
|
|
t.Fatalf("UnpublishFromHub() error = %v", err)
|
|
}
|
|
if item == nil || !strings.EqualFold(item.Visibility, skillVisibilityPrivate) {
|
|
t.Fatalf("expected admin unpublish to succeed, got %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestUnpublishRemovesFromPublicCatalog(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{10: {ID: 10, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: {ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"}},
|
|
}
|
|
svc := &skillService{repo: stub}
|
|
if _, err := svc.UnpublishFromHub(1, "user", 1); err != nil {
|
|
t.Fatalf("UnpublishFromHub() error = %v", err)
|
|
}
|
|
if stub.skills[1].Visibility != skillVisibilityPrivate {
|
|
t.Fatalf("expected private visibility after unpublish, got %q", stub.skills[1].Visibility)
|
|
}
|
|
publicSkills, err := stub.ListPublicHubSkills()
|
|
if err != nil {
|
|
t.Fatalf("ListPublicHubSkills() error = %v", err)
|
|
}
|
|
if len(publicSkills) != 0 {
|
|
t.Fatalf("catalog source should be empty after unpublish, got %d items", len(publicSkills))
|
|
}
|
|
}
|
|
|
|
func TestListAllHubSkillsAdminExcludesDeleted(t *testing.T) {
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive, SourceType: skillSourceUploaded, Visibility: skillVisibilityPublic},
|
|
2: {ID: 2, UserID: 1, SkillKey: "demo__deleted_2", Name: "Demo", Status: skillStatusDeleted, SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate},
|
|
},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
svc := &skillService{repo: stub}
|
|
items, err := svc.ListAllHubSkillsAdmin()
|
|
if err != nil {
|
|
t.Fatalf("ListAllHubSkillsAdmin() error = %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("ListAllHubSkillsAdmin() len = %d, want 1", len(items))
|
|
}
|
|
if items[0].ID != 1 {
|
|
t.Fatalf("ListAllHubSkillsAdmin() id = %d, want 1", items[0].ID)
|
|
}
|
|
}
|
|
|
|
type importTestInstanceRepo struct {
|
|
instances map[int]*models.Instance
|
|
}
|
|
|
|
func (r *importTestInstanceRepo) Create(*models.Instance) error { panic("not used") }
|
|
func (r *importTestInstanceRepo) GetByID(id int) (*models.Instance, error) {
|
|
if inst, ok := r.instances[id]; ok {
|
|
copy := *inst
|
|
return ©, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
func (r *importTestInstanceRepo) GetByAccessToken(string) (*models.Instance, error) { panic("not used") }
|
|
func (r *importTestInstanceRepo) GetByAgentBootstrapToken(string) (*models.Instance, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) GetAll(int, int) ([]models.Instance, error) {
|
|
items := make([]models.Instance, 0, len(r.instances))
|
|
for _, inst := range r.instances {
|
|
items = append(items, *inst)
|
|
}
|
|
return items, nil
|
|
}
|
|
func (r *importTestInstanceRepo) CountAll() (int, error) { panic("not used") }
|
|
func (r *importTestInstanceRepo) GetByUserID(int, int, int) ([]models.Instance, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) CountByUserID(int) (int, error) { panic("not used") }
|
|
func (r *importTestInstanceRepo) CountActiveByMode(context.Context, string) (int, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) ExistsByUserIDAndName(int, string) (bool, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) GetAllRunning() ([]models.Instance, error) { panic("not used") }
|
|
func (r *importTestInstanceRepo) GetV2DesiredRunning(context.Context, int) ([]models.Instance, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) GetV2Creating(context.Context, int) ([]models.Instance, error) {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) UpdateRuntimeState(context.Context, int, string, int, *string) error {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) SetWorkspacePath(context.Context, int, string) error {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) UpdateWorkspaceUsage(context.Context, int, int64) error {
|
|
panic("not used")
|
|
}
|
|
func (r *importTestInstanceRepo) Update(*models.Instance) error { panic("not used") }
|
|
func (r *importTestInstanceRepo) Delete(int) error { panic("not used") }
|
|
|
|
type noopInstanceCommandService struct{}
|
|
|
|
func (n *noopInstanceCommandService) Create(int, *int, CreateInstanceCommandRequest) (*InstanceCommandPayload, error) {
|
|
return nil, nil
|
|
}
|
|
func (n *noopInstanceCommandService) GetNextForAgent(*AgentSession) (*AgentCommandEnvelope, error) {
|
|
panic("not used")
|
|
}
|
|
func (n *noopInstanceCommandService) MarkStarted(*AgentSession, int, *time.Time) error {
|
|
panic("not used")
|
|
}
|
|
func (n *noopInstanceCommandService) MarkFinished(*AgentSession, int, AgentCommandFinishRequest) error {
|
|
panic("not used")
|
|
}
|
|
func (n *noopInstanceCommandService) ListByInstanceID(int, int) ([]InstanceCommandPayload, error) {
|
|
panic("not used")
|
|
}
|
|
|
|
func TestPublishFromInstanceRejectsDiscoveredSkill(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "key.zip"}},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
_, err := svc.PublishFromInstance(1, "user", 1, 1, []int{1})
|
|
if err == nil || err.Error() != "skill_not_in_library" {
|
|
t.Fatalf("expected skill_not_in_library, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryPendingPackage(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
tagAssignments: map[int][]int{},
|
|
tags: map[int]*models.SkillHubTag{
|
|
1: {ID: 1, TagKey: "coding", Name: "Coding", AdminOnly: false},
|
|
},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
|
if err == nil || err.Error() != "skill_package_pending" {
|
|
t.Fatalf("expected skill_package_pending, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryRejectsNonOwner(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""}},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
_, err := svc.ImportInstanceSkillToLibrary(2, "user", 1, 1)
|
|
if err == nil || err.Error() != "access denied" {
|
|
t.Fatalf("expected access denied, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryRejectsDeletedSkill(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusDeleted,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID}},
|
|
blobs: map[int]*models.SkillBlob{blobID: {ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: "discovered/1/demo.zip"}},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
|
if err == nil || err.Error() != "skill not found" {
|
|
t.Fatalf("expected skill not found, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryPromotesDiscoveredSkill(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
scanResultID := 99
|
|
objectKey := "discovered/1/demo.zip"
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceDiscovered}},
|
|
blobs: map[int]*models.SkillBlob{
|
|
blobID: {
|
|
ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: objectKey,
|
|
LastScanResultID: &scanResultID,
|
|
},
|
|
},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
storage := &importTestObjectStorage{objects: map[string][]byte{objectKey: []byte("fake-zip")}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}, storage: storage}
|
|
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
|
if err != nil {
|
|
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
|
}
|
|
if stub.skills[1].SourceType != skillSourceUploaded {
|
|
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
|
}
|
|
if stub.versions[versionID].SourceType != skillSourceUploaded {
|
|
t.Fatalf("version source_type = %q, want %q", stub.versions[versionID].SourceType, skillSourceUploaded)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryLiteMaterializesPackage(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
contentHash := "abc123def456789012345678901234"
|
|
workspaceDir := "yuanbao"
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "yuanbao", Name: "yuanbao", Status: skillStatusActive,
|
|
SourceType: skillSourceDiscovered, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceDiscovered}},
|
|
blobs: map[int]*models.SkillBlob{
|
|
blobID: {ID: blobID, ContentHash: contentHash, ScanStatus: "pending", RiskLevel: skillRiskUnknown, ObjectKey: ""},
|
|
},
|
|
instanceSkills: []models.InstanceSkill{{
|
|
InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance",
|
|
WorkspaceDir: &workspaceDir,
|
|
}},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{
|
|
1: {ID: 1, UserID: 1, InstanceMode: InstanceModeLite, RuntimeType: RuntimeBackendGateway},
|
|
}}
|
|
storage := &importTestObjectStorage{objects: map[string][]byte{}}
|
|
cmdSvc := &capturingInstanceCommandService{}
|
|
matSvc := NewSkillPackageMaterializeService(
|
|
&materializeJobRepoStub{},
|
|
stub,
|
|
importLiteMaterializer{repo: stub, storage: storage, blobID: blobID},
|
|
)
|
|
svc := &skillService{
|
|
repo: stub, instanceRepo: instRepo, commandService: cmdSvc, storage: storage, materializeService: matSvc,
|
|
}
|
|
_, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
|
if err != nil {
|
|
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
|
}
|
|
for _, req := range cmdSvc.created {
|
|
if req.CommandType == InstanceCommandTypeCollectSkillPackage {
|
|
t.Fatalf("unexpected collect_skill_package command: %#v", req)
|
|
}
|
|
}
|
|
if stub.skills[1].SourceType != skillSourceUploaded {
|
|
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
|
}
|
|
blob := stub.blobs[blobID]
|
|
if blob == nil || strings.TrimSpace(blob.ObjectKey) == "" {
|
|
t.Fatalf("expected materialized object key, got %#v", blob)
|
|
}
|
|
}
|
|
|
|
func TestImportInstanceSkillToLibraryIdempotentForUploaded(t *testing.T) {
|
|
versionID := 10
|
|
blobID := 20
|
|
scanResultID := 99
|
|
objectKey := "user/1/demo.zip"
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {
|
|
ID: 1, UserID: 1, SkillKey: "demo", Name: "Demo", Status: skillStatusActive,
|
|
SourceType: skillSourceUploaded, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
versions: map[int]*models.SkillVersion{versionID: {ID: versionID, SkillID: 1, BlobID: blobID, SourceType: skillSourceUploaded}},
|
|
blobs: map[int]*models.SkillBlob{
|
|
blobID: {
|
|
ID: blobID, ScanStatus: "completed", RiskLevel: skillRiskNone, ObjectKey: objectKey,
|
|
LastScanResultID: &scanResultID,
|
|
},
|
|
},
|
|
instanceSkills: []models.InstanceSkill{{InstanceID: 1, SkillID: 1, Status: "active", SourceType: "discovered_in_instance"}},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
storage := &importTestObjectStorage{objects: map[string][]byte{objectKey: []byte("fake-zip")}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}, storage: storage}
|
|
payload, err := svc.ImportInstanceSkillToLibrary(1, "user", 1, 1)
|
|
if err != nil {
|
|
t.Fatalf("ImportInstanceSkillToLibrary() error = %v", err)
|
|
}
|
|
if stub.skills[1].SourceType != skillSourceUploaded {
|
|
t.Fatalf("skill source_type = %q, want %q", stub.skills[1].SourceType, skillSourceUploaded)
|
|
}
|
|
if payload == nil || payload.SourceType != skillSourceUploaded {
|
|
t.Fatalf("payload source_type = %v, want %q", payload, skillSourceUploaded)
|
|
}
|
|
}
|
|
|
|
func TestSyncAgentSkillsCreatesDiscoveredSkillWithPrivateVisibility(t *testing.T) {
|
|
stub := &capturingSkillRepoStub{
|
|
skillRepoStub: skillRepoStub{
|
|
skills: map[int]*models.Skill{},
|
|
blobs: map[int]*models.SkillBlob{},
|
|
versions: map[int]*models.SkillVersion{},
|
|
},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
|
Skills: []AgentSkillRecord{{
|
|
Identifier: "weather",
|
|
ContentMD5: "abc123def456789012345678901234",
|
|
Source: "discovered_in_instance",
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SyncAgentSkills() error = %v", err)
|
|
}
|
|
if len(stub.createdSkills) != 1 {
|
|
t.Fatalf("created %d skills, want 1", len(stub.createdSkills))
|
|
}
|
|
if stub.createdSkills[0].Visibility != skillVisibilityPrivate {
|
|
t.Fatalf("visibility = %q, want %q", stub.createdSkills[0].Visibility, skillVisibilityPrivate)
|
|
}
|
|
}
|
|
|
|
type capturingSkillRepoStub struct {
|
|
skillRepoStub
|
|
createdSkills []*models.Skill
|
|
nextSkillID int
|
|
markMissingCalls int
|
|
lastMarkMissingActive []int
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) CreateSkill(skill *models.Skill) error {
|
|
s.nextSkillID++
|
|
skill.ID = s.nextSkillID
|
|
copy := *skill
|
|
s.createdSkills = append(s.createdSkills, ©)
|
|
if s.skills == nil {
|
|
s.skills = map[int]*models.Skill{}
|
|
}
|
|
stored := *skill
|
|
s.skills[skill.ID] = &stored
|
|
return nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) CreateBlob(blob *models.SkillBlob) error {
|
|
if s.blobs == nil {
|
|
s.blobs = map[int]*models.SkillBlob{}
|
|
}
|
|
s.nextSkillID++
|
|
blob.ID = s.nextSkillID
|
|
stored := *blob
|
|
s.blobs[blob.ID] = &stored
|
|
return nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) CreateVersion(version *models.SkillVersion) error {
|
|
if s.versions == nil {
|
|
s.versions = map[int]*models.SkillVersion{}
|
|
}
|
|
s.nextSkillID++
|
|
version.ID = s.nextSkillID
|
|
stored := *version
|
|
s.versions[version.ID] = &stored
|
|
return nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) GetBlobByContentHash(hash string) (*models.SkillBlob, error) {
|
|
for _, blob := range s.blobs {
|
|
if blob != nil && blob.ContentHash == hash {
|
|
copy := *blob
|
|
return ©, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) {
|
|
for _, version := range s.versions {
|
|
if version != nil && version.SkillID == skillID && version.BlobID == blobID {
|
|
copy := *version
|
|
return ©, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) UpsertInstanceSkill(item *models.InstanceSkill) error {
|
|
copy := *item
|
|
updated := false
|
|
for i, existing := range s.instanceSkills {
|
|
if existing.InstanceID == item.InstanceID && existing.SkillID == item.SkillID {
|
|
s.instanceSkills[i] = copy
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
if !updated {
|
|
s.instanceSkills = append(s.instanceSkills, copy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *capturingSkillRepoStub) MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error {
|
|
s.markMissingCalls++
|
|
s.lastMarkMissingActive = append([]int(nil), activeSkillIDs...)
|
|
active := map[int]struct{}{}
|
|
for _, id := range activeSkillIDs {
|
|
active[id] = struct{}{}
|
|
}
|
|
for i := range s.instanceSkills {
|
|
item := &s.instanceSkills[i]
|
|
if item.InstanceID != instanceID {
|
|
continue
|
|
}
|
|
if _, ok := active[item.SkillID]; ok {
|
|
continue
|
|
}
|
|
if strings.EqualFold(item.Status, "removed") {
|
|
continue
|
|
}
|
|
item.Status = "missing"
|
|
item.RemovedAt = &observedAt
|
|
item.UpdatedAt = observedAt
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type importLiteMaterializer struct {
|
|
repo *skillRepoStub
|
|
storage *importTestObjectStorage
|
|
blobID int
|
|
}
|
|
|
|
func (m importLiteMaterializer) materializeSkillPackageFromWorkspace(_ context.Context, instanceID int, workspaceDir, contentHash string, _ int) (*models.SkillBlob, error) {
|
|
objectKey := fmt.Sprintf("discovered/%d/%s/%s.zip", instanceID, workspaceDir, contentHash)
|
|
if m.storage.objects == nil {
|
|
m.storage.objects = map[string][]byte{}
|
|
}
|
|
m.storage.objects[objectKey] = []byte("fake-zip")
|
|
scanID := 99
|
|
blob := m.repo.blobs[m.blobID]
|
|
blob.ObjectKey = objectKey
|
|
blob.ScanStatus = "completed"
|
|
blob.RiskLevel = skillRiskNone
|
|
blob.LastScanResultID = &scanID
|
|
m.repo.blobs[m.blobID] = blob
|
|
return blob, nil
|
|
}
|
|
|
|
func (importLiteMaterializer) syncSkillRecordFromBlob(int, *models.SkillBlob) error { return nil }
|
|
|
|
type importTestObjectStorage struct {
|
|
objects map[string][]byte
|
|
}
|
|
|
|
func (s *importTestObjectStorage) PutObject(_ context.Context, objectKey string, body []byte, _ string) error {
|
|
if s.objects == nil {
|
|
s.objects = map[string][]byte{}
|
|
}
|
|
s.objects[objectKey] = body
|
|
return nil
|
|
}
|
|
|
|
func (s *importTestObjectStorage) GetObject(_ context.Context, objectKey string) ([]byte, error) {
|
|
if body, ok := s.objects[objectKey]; ok {
|
|
return body, nil
|
|
}
|
|
return nil, fmt.Errorf("object not found: %s", objectKey)
|
|
}
|
|
|
|
func TestDeleteSkillReleasesSkillKey(t *testing.T) {
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {ID: 1, UserID: 1, SkillKey: "weather", Name: "Weather", Status: skillStatusActive, SourceType: skillSourceUploaded},
|
|
},
|
|
tagAssignments: map[int][]int{},
|
|
}
|
|
svc := &skillService{repo: stub}
|
|
if err := svc.DeleteSkill(1, "user", 1); err != nil {
|
|
t.Fatalf("DeleteSkill() error = %v", err)
|
|
}
|
|
want := deletedSkillKey("weather", 1)
|
|
if stub.skills[1].SkillKey != want {
|
|
t.Fatalf("skill_key = %q, want %q", stub.skills[1].SkillKey, want)
|
|
}
|
|
active, err := stub.GetSkillByUserKey(1, "weather")
|
|
if err != nil {
|
|
t.Fatalf("GetSkillByUserKey() error = %v", err)
|
|
}
|
|
if active != nil {
|
|
t.Fatal("original skill_key should be released for re-import")
|
|
}
|
|
}
|
|
|
|
func TestSyncAgentSkillsIncrementalDoesNotMarkMissing(t *testing.T) {
|
|
contentHash := "abc123def456789012345678901234"
|
|
versionID := 1
|
|
stub := &capturingSkillRepoStub{
|
|
skillRepoStub: skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
10: {
|
|
ID: 10, UserID: 1, SkillKey: "weather", Name: "weather",
|
|
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
|
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
blobs: map[int]*models.SkillBlob{
|
|
1: {ID: 1, ContentHash: contentHash, ObjectKey: "discovered/weather.zip", ScanStatus: "completed"},
|
|
},
|
|
versions: map[int]*models.SkillVersion{
|
|
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
|
},
|
|
instanceSkills: []models.InstanceSkill{
|
|
{InstanceID: 1, SkillID: 10, Status: "active", SourceType: "discovered_in_instance"},
|
|
},
|
|
},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
|
|
if err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{Mode: "incremental", Skills: nil}); err != nil {
|
|
t.Fatalf("incremental SyncAgentSkills() error = %v", err)
|
|
}
|
|
if stub.markMissingCalls != 0 {
|
|
t.Fatalf("markMissingCalls = %d, want 0 for incremental", stub.markMissingCalls)
|
|
}
|
|
if stub.instanceSkills[0].Status != "active" {
|
|
t.Fatalf("status = %q, want active after incremental empty report", stub.instanceSkills[0].Status)
|
|
}
|
|
|
|
if err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{Mode: "full", Skills: nil}); err != nil {
|
|
t.Fatalf("full SyncAgentSkills() error = %v", err)
|
|
}
|
|
if stub.markMissingCalls != 1 {
|
|
t.Fatalf("markMissingCalls = %d, want 1 for full", stub.markMissingCalls)
|
|
}
|
|
if stub.instanceSkills[0].Status != "missing" {
|
|
t.Fatalf("status = %q, want missing after full empty report", stub.instanceSkills[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestSyncAgentSkillsReactivatesMissingButNotRemoved(t *testing.T) {
|
|
contentHash := "abc123def456789012345678901234"
|
|
versionID := 1
|
|
removedAt := time.Now().UTC().Add(-time.Hour)
|
|
stub := &capturingSkillRepoStub{
|
|
skillRepoStub: skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
10: {
|
|
ID: 10, UserID: 1, SkillKey: "weather", Name: "weather",
|
|
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
|
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
11: {
|
|
ID: 11, UserID: 1, SkillKey: "calendar", Name: "calendar",
|
|
SourceType: skillSourceDiscovered, Status: skillStatusActive,
|
|
Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID,
|
|
},
|
|
},
|
|
blobs: map[int]*models.SkillBlob{
|
|
1: {ID: 1, ContentHash: contentHash, ObjectKey: "discovered/weather.zip", ScanStatus: "completed"},
|
|
2: {ID: 2, ContentHash: "def456abc123789012345678901234", ObjectKey: "discovered/calendar.zip", ScanStatus: "completed"},
|
|
},
|
|
versions: map[int]*models.SkillVersion{
|
|
1: {ID: 1, SkillID: 10, BlobID: 1, VersionNo: 1},
|
|
2: {ID: 2, SkillID: 11, BlobID: 2, VersionNo: 1},
|
|
},
|
|
instanceSkills: []models.InstanceSkill{
|
|
{InstanceID: 1, SkillID: 10, Status: "missing", SourceType: "discovered_in_instance", RemovedAt: &removedAt},
|
|
{InstanceID: 1, SkillID: 11, Status: "removed", SourceType: "discovered_in_instance", RemovedAt: &removedAt},
|
|
},
|
|
},
|
|
}
|
|
instRepo := &importTestInstanceRepo{instances: map[int]*models.Instance{1: {ID: 1, UserID: 1}}}
|
|
svc := &skillService{repo: stub, instanceRepo: instRepo, commandService: &noopInstanceCommandService{}}
|
|
|
|
err := svc.SyncAgentSkills(1, AgentSkillInventoryReportRequest{
|
|
Mode: "full",
|
|
Skills: []AgentSkillRecord{
|
|
{Identifier: "weather", ContentMD5: contentHash, Source: "discovered_in_instance"},
|
|
{Identifier: "calendar", ContentMD5: "def456abc123789012345678901234", Source: "discovered_in_instance"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SyncAgentSkills() error = %v", err)
|
|
}
|
|
|
|
var missing, removed *models.InstanceSkill
|
|
for i := range stub.instanceSkills {
|
|
item := &stub.instanceSkills[i]
|
|
switch item.SkillID {
|
|
case 10:
|
|
missing = item
|
|
case 11:
|
|
removed = item
|
|
}
|
|
}
|
|
if missing == nil || missing.Status != "active" || missing.RemovedAt != nil {
|
|
t.Fatalf("missing skill revive = %#v, want active with nil RemovedAt", missing)
|
|
}
|
|
if removed == nil || removed.Status != "removed" {
|
|
t.Fatalf("removed skill = %#v, want status removed", removed)
|
|
}
|
|
}
|
|
|
|
func TestDownloadSkillNilSafe(t *testing.T) {
|
|
missingVersionID := 999
|
|
versionID := 7
|
|
stub := &skillRepoStub{
|
|
skills: map[int]*models.Skill{
|
|
1: {ID: 1, UserID: 1, Status: skillStatusActive, Visibility: skillVisibilityPrivate, CurrentVersionID: &missingVersionID},
|
|
2: {ID: 2, UserID: 1, Status: skillStatusActive, Visibility: skillVisibilityPrivate, CurrentVersionID: &versionID},
|
|
},
|
|
versions: map[int]*models.SkillVersion{
|
|
7: {ID: 7, SkillID: 2, BlobID: 99},
|
|
},
|
|
blobs: map[int]*models.SkillBlob{},
|
|
}
|
|
svc := &skillService{repo: stub, storage: &importTestObjectStorage{objects: map[string][]byte{}}}
|
|
|
|
if _, _, err := svc.DownloadSkill(1, "user", 1); err == nil {
|
|
t.Fatal("expected error when current version is missing")
|
|
}
|
|
if _, _, err := svc.DownloadSkill(1, "user", 2); err == nil {
|
|
t.Fatal("expected error when blob is missing")
|
|
}
|
|
}
|