Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
259 lines
12 KiB
Go
259 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/repository"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestGlobalInstructionSkillCanBeReadMountedButNotOverwrittenByOthers(t *testing.T) {
|
|
s, alice := instructionHarness(t)
|
|
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
|
skill, err := s.Import(alice, "global.md", "", "", []byte("shared rules"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("visibility", "global").Error)
|
|
view, err := s.Get(bob, skill.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "shared rules", view.Instructions)
|
|
require.Equal(t, "global", view.Visibility)
|
|
require.True(t, view.Enabled)
|
|
list, err := s.List(bob)
|
|
require.NoError(t, err)
|
|
require.Len(t, list, 1)
|
|
agent := types.CustomAgent{ID: "bob-agent", TenantID: 1, CreatedBy: "bob", Name: "Bob"}
|
|
require.NoError(t, s.db.Create(&agent).Error)
|
|
_, err = s.Mount(bob, InstructionSkillMountRequest{AgentIDs: []string{agent.ID}, SkillIDs: []string{skill.ID}})
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.db.First(&agent, "id = ?", agent.ID).Error)
|
|
prompt, err := s.ResolveAgentInstructions(bob, &agent)
|
|
require.NoError(t, err)
|
|
require.Contains(t, prompt, "shared rules")
|
|
require.Error(t, s.Delete(bob, skill.ID, true))
|
|
copy, err := s.Import(bob, "global.md", "", "", []byte("bob rules"))
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, skill.ID, copy.ID)
|
|
original, err := s.Get(alice, skill.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "shared rules", original.Instructions)
|
|
}
|
|
|
|
func TestInstructionLifecycleIsCheckedAtExecutionTime(t *testing.T) {
|
|
s, alice := instructionHarness(t)
|
|
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
|
skill, err := s.Import(alice, "rules.md", "", "", []byte("sensitive rules"))
|
|
require.NoError(t, err)
|
|
agent := types.CustomAgent{ID: "shared-agent", TenantID: 1, CreatedBy: "alice", Name: "Shared", Visibility: "global", Enabled: true, Config: types.CustomAgentConfig{InstructionSkillIDs: []string{skill.ID}}}
|
|
prompt, err := s.ResolveAgentInstructions(bob, &agent)
|
|
require.NoError(t, err, "sharing an Agent includes execution of its owner's bound rules")
|
|
require.Contains(t, prompt, "sensitive rules")
|
|
_, err = s.Get(bob, skill.ID)
|
|
require.Error(t, err, "Agent execution must not independently expose the private skill")
|
|
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("visibility", "global").Error)
|
|
prompt, err = s.ResolveAgentInstructions(bob, &agent)
|
|
require.NoError(t, err)
|
|
require.Contains(t, prompt, "sensitive rules")
|
|
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("enabled", false).Error)
|
|
for _, ctx := range []context.Context{alice, bob, instructionContext(1, "admin", types.TenantRoleAdmin)} {
|
|
prompt, err = s.ResolveAgentInstructions(ctx, &agent)
|
|
require.Error(t, err)
|
|
require.Empty(t, prompt)
|
|
}
|
|
_, err = s.Get(bob, skill.ID)
|
|
require.Error(t, err)
|
|
list, err := s.List(bob)
|
|
require.NoError(t, err)
|
|
require.Empty(t, list)
|
|
updated, err := s.Import(alice, "rules.md", "", "", []byte("revised rules"))
|
|
require.NoError(t, err)
|
|
require.False(t, updated.Enabled, "reimport must not reactivate a disabled skill")
|
|
require.Equal(t, "global", updated.Visibility)
|
|
agent.Config.InstructionSkillIDs = nil
|
|
agent.Enabled = false
|
|
_, err = s.ResolveAgentInstructions(alice, &agent)
|
|
require.ErrorContains(t, err, "停用")
|
|
agent.Enabled = true
|
|
agent.Config.InstructionSkillIDs = []string{skill.ID}
|
|
_, err = s.ResolveAgentInstructions(instructionContext(2, "admin", types.TenantRoleAdmin), &agent)
|
|
require.ErrorContains(t, err, "跨工作空间")
|
|
}
|
|
|
|
func TestNativeOrganizationExecutionWithoutInstructionSkillsKeepsCompatibility(t *testing.T) {
|
|
shared := &types.CustomAgent{ID: "shared", TenantID: 1, CreatedBy: "alice", Visibility: "private", Enabled: true}
|
|
// The organization-sharing service verifies a share before handing this
|
|
// Agent to QA. There are no instruction bodies to authorize or expose.
|
|
prompt, err := ResolveAgentInstructionPrompt(instructionContext(2, "bob", types.TenantRoleContributor), nil, shared)
|
|
require.NoError(t, err)
|
|
require.Empty(t, prompt)
|
|
shared.Enabled = false
|
|
_, err = ResolveAgentInstructionPrompt(instructionContext(2, "bob", types.TenantRoleAdmin), nil, shared)
|
|
require.ErrorContains(t, err, "停用")
|
|
}
|
|
|
|
func TestDisabledInstructionSkillCannotBeMounted(t *testing.T) {
|
|
s, alice := instructionHarness(t)
|
|
skill, err := s.Import(alice, "disabled.md", "", "", []byte("disabled rules"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id = ?", skill.ID).Update("enabled", false).Error)
|
|
require.NoError(t, s.db.Create(&types.CustomAgent{ID: "a", TenantID: 1, CreatedBy: "alice", Name: "A"}).Error)
|
|
_, err = s.Mount(alice, InstructionSkillMountRequest{AgentIDs: []string{"a"}, SkillIDs: []string{skill.ID}})
|
|
require.ErrorContains(t, err, "停用")
|
|
require.NoError(t, s.db.Model(&types.CustomAgent{}).Where("id = ?", "a").Updates(map[string]any{"enabled": false, "config": types.CustomAgentConfig{InstructionSkillIDs: []string{skill.ID}}}).Error)
|
|
_, err = s.Mount(alice, InstructionSkillMountRequest{AgentIDs: []string{"a"}, SkillIDs: []string{skill.ID}, Mode: "remove"})
|
|
require.NoError(t, err, "owners must be able to repair disabled Agent bindings")
|
|
var repaired types.CustomAgent
|
|
require.NoError(t, s.db.First(&repaired, "id = ?", "a").Error)
|
|
require.False(t, repaired.Enabled)
|
|
require.Empty(t, repaired.Config.InstructionSkillIDs)
|
|
}
|
|
|
|
func TestDisabledBuiltinOverrideIsNotResurrected(t *testing.T) {
|
|
ids := types.GetBuiltinAgentIDs()
|
|
if len(ids) == 0 {
|
|
t.Skip("no builtin registry configured")
|
|
}
|
|
id := ids[0]
|
|
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{id: {ID: id, TenantID: 1, IsBuiltin: true, Visibility: "global", Enabled: false}}}
|
|
s := &customAgentService{repo: repo}
|
|
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
|
_, err := s.GetAgentByID(bob, id)
|
|
require.ErrorIs(t, err, ErrAgentNotFound)
|
|
list, err := s.ListAgents(bob)
|
|
require.NoError(t, err)
|
|
for _, a := range list {
|
|
require.NotEqual(t, id, a.ID)
|
|
}
|
|
admin := instructionContext(1, "admin", types.TenantRoleAdmin)
|
|
a, err := s.GetAgentByID(admin, id)
|
|
require.NoError(t, err)
|
|
_, err = ResolveAgentInstructionPrompt(admin, nil, a)
|
|
require.ErrorContains(t, err, "停用")
|
|
}
|
|
|
|
func TestLifecycleCreateDefaultsPreserveExplicitDisabled(t *testing.T) {
|
|
s, _ := instructionHarness(t)
|
|
agent := types.CustomAgent{ID: "disabled", TenantID: 1, Name: "D", Visibility: "private", Enabled: false}
|
|
require.NoError(t, s.db.Create(&agent).Error)
|
|
var stored types.CustomAgent
|
|
require.NoError(t, s.db.First(&stored, "id = ?", agent.ID).Error)
|
|
require.False(t, stored.Enabled)
|
|
legacy := types.CustomAgent{ID: "legacy", TenantID: 1, Name: "L"}
|
|
require.NoError(t, s.db.Create(&legacy).Error)
|
|
require.True(t, legacy.Enabled)
|
|
require.Equal(t, "global", legacy.Visibility)
|
|
skill := types.TenantSkillCatalogEntity{ID: "disabled-skill", TenantID: 1, Name: "D", InstructionOnly: true, Visibility: "private", Enabled: false}
|
|
require.NoError(t, s.db.Create(&skill).Error)
|
|
var storedSkill types.TenantSkillCatalogEntity
|
|
require.NoError(t, s.db.First(&storedSkill, "id = ?", skill.ID).Error)
|
|
require.False(t, storedSkill.Enabled)
|
|
}
|
|
|
|
type lifecycleAgentRepo struct {
|
|
interfaces.CustomAgentRepository
|
|
rows map[string]*types.CustomAgent
|
|
writes int
|
|
}
|
|
|
|
func (r *lifecycleAgentRepo) CreateAgent(_ context.Context, a *types.CustomAgent) error {
|
|
r.writes++
|
|
r.rows[a.ID] = a
|
|
return nil
|
|
}
|
|
|
|
func (r *lifecycleAgentRepo) GetAgentByID(_ context.Context, id string, tenant uint64) (*types.CustomAgent, error) {
|
|
a := r.rows[id]
|
|
if a == nil || a.TenantID != tenant {
|
|
return nil, repository.ErrCustomAgentNotFound
|
|
}
|
|
copy := *a
|
|
return ©, nil
|
|
}
|
|
func (r *lifecycleAgentRepo) ListAgentsByTenantID(_ context.Context, tenant uint64) ([]*types.CustomAgent, error) {
|
|
out := []*types.CustomAgent{}
|
|
for _, a := range r.rows {
|
|
if a.TenantID == tenant {
|
|
copy := *a
|
|
out = append(out, ©)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
func (r *lifecycleAgentRepo) UpdateAgent(_ context.Context, a *types.CustomAgent) error {
|
|
r.writes++
|
|
r.rows[a.ID] = a
|
|
return nil
|
|
}
|
|
func (r *lifecycleAgentRepo) DeleteAgent(_ context.Context, id string, _ uint64) error {
|
|
r.writes++
|
|
delete(r.rows, id)
|
|
return nil
|
|
}
|
|
|
|
func TestNativeAgentLifecycleReadWriteAndTenantIsolation(t *testing.T) {
|
|
repo := &lifecycleAgentRepo{rows: map[string]*types.CustomAgent{
|
|
"private": {ID: "private", TenantID: 1, CreatedBy: "alice", Name: "Private", Visibility: "private", Enabled: true},
|
|
"global": {ID: "global", TenantID: 1, CreatedBy: "alice", Name: "Global", Visibility: "global", Enabled: true},
|
|
"disabled": {ID: "disabled", TenantID: 1, CreatedBy: "alice", Name: "Disabled", Visibility: "global", Enabled: false},
|
|
"legacy": {ID: "legacy", TenantID: 1, CreatedBy: "alice", Name: "Legacy"},
|
|
}}
|
|
s := &customAgentService{repo: repo}
|
|
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
|
for _, id := range []string{"private", "disabled"} {
|
|
_, err := s.GetAgentByID(bob, id)
|
|
require.ErrorIs(t, err, ErrAgentNotFound)
|
|
}
|
|
for _, id := range []string{"global", "legacy"} {
|
|
a, err := s.GetAgentByID(bob, id)
|
|
require.NoError(t, err)
|
|
require.True(t, a.Enabled)
|
|
}
|
|
list, err := s.ListAgents(bob)
|
|
require.NoError(t, err)
|
|
ids := []string{}
|
|
for _, a := range list {
|
|
ids = append(ids, a.ID)
|
|
}
|
|
require.Contains(t, ids, "global")
|
|
require.NotContains(t, ids, "private")
|
|
require.NotContains(t, ids, "disabled")
|
|
_, err = s.UpdateAgent(bob, &types.CustomAgent{ID: "global", Name: "stolen"})
|
|
require.Error(t, err)
|
|
require.Error(t, s.DeleteAgent(bob, "global"))
|
|
_, err = s.CopyAgent(bob, "global")
|
|
require.ErrorContains(t, err, "不能复制")
|
|
require.Zero(t, repo.writes)
|
|
shareService := &agentShareService{agentRepo: repo}
|
|
_, err = shareService.ShareAgent(bob, "private", "organization", "bob", 1, types.OrgRoleViewer)
|
|
require.ErrorIs(t, err, ErrNotAgentOwner, "private Agents cannot be exposed by creating an organization share")
|
|
_, err = s.GetAgentByIDAndTenant(instructionContext(2, "admin", types.TenantRoleAdmin), "global", 1)
|
|
require.ErrorIs(t, err, ErrAgentNotFound)
|
|
alice := instructionContext(1, "alice", types.TenantRoleContributor)
|
|
a, err := s.UpdateAgent(alice, &types.CustomAgent{ID: "disabled", Name: "renamed"})
|
|
require.NoError(t, err)
|
|
require.False(t, a.Enabled, "old clients must preserve lifecycle fields")
|
|
require.NoError(t, s.DeleteAgent(alice, "private"))
|
|
}
|
|
|
|
func TestInstructionCatalogCannotLeakThroughExecutableCatalogFallback(t *testing.T) {
|
|
s, alice := instructionHarness(t)
|
|
require.NoError(t, s.db.AutoMigrate(&types.TenantSkillEntity{}))
|
|
skill, err := s.Import(alice, "secret.md", "", "", []byte("private body"))
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.db.Create(&types.TenantSkillEntity{ID: "installation", TenantID: 1, CatalogID: skill.ID, Name: "secret", Enabled: true}).Error)
|
|
service := NewTenantSkillService(repository.NewTenantSkillRepository(s.db), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
|
bob := instructionContext(1, "bob", types.TenantRoleContributor)
|
|
list, err := service.ListCatalog(bob, 1)
|
|
require.NoError(t, err)
|
|
require.Empty(t, list)
|
|
for _, id := range []string{skill.ID, "installation"} {
|
|
_, err = service.resolveCatalog(bob, 1, id)
|
|
require.Error(t, err)
|
|
}
|
|
list, err = service.ListCatalog(instructionContext(2, "admin", types.TenantRoleAdmin), 1)
|
|
require.NoError(t, err)
|
|
require.Empty(t, list)
|
|
}
|