You've already forked agentic-coding-workflow
完善 MCP 工具
This commit is contained in:
@@ -219,11 +219,15 @@ type SSEEvent struct {
|
||||
}
|
||||
|
||||
// TemplateService — prompt_templates CRUD。
|
||||
//
|
||||
// 鉴权语义:user 模板仅 owner 可改/删;system 模板仅 admin 可改/删
|
||||
// (isAdmin 由调用方解析后传入;Create 的 system-scope admin 校验
|
||||
// 仍在 HTTP handler / MCP 工具层,与既有行为一致)。
|
||||
type TemplateService interface {
|
||||
List(ctx context.Context, userID uuid.UUID) ([]PromptTemplate, error)
|
||||
Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error)
|
||||
Update(ctx context.Context, userID, id uuid.UUID, name, content string) error
|
||||
Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error
|
||||
Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error
|
||||
}
|
||||
|
||||
// EndpointService — admin LLM endpoints / models CRUD(管理员守卫上层处理)。
|
||||
|
||||
@@ -492,7 +492,12 @@ func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
||||
return
|
||||
}
|
||||
if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil {
|
||||
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.tplSvc.Update(r.Context(), uid, isAdmin, id, req.Name, req.Content); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -510,7 +515,12 @@ func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil {
|
||||
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := h.tplSvc.Delete(r.Context(), uid, isAdmin, id); err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,8 +118,10 @@ func (f *fakeTplSvc) Create(ctx context.Context, userID uuid.UUID, name, content
|
||||
}
|
||||
return &PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
func (f *fakeTplSvc) Update(_ context.Context, _, _ uuid.UUID, _, _ string) error { return nil }
|
||||
func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||
func (f *fakeTplSvc) Update(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTplSvc) Delete(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) error { return nil }
|
||||
|
||||
// ===== Fake EndpointService =====
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func (s *templateService) Create(ctx context.Context, userID uuid.UUID, name, co
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name, content string) error {
|
||||
func (s *templateService) Update(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID, name, content string) error {
|
||||
t, err := s.repo.GetPromptTemplate(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -62,8 +62,8 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
|
||||
if t == nil {
|
||||
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
|
||||
}
|
||||
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
|
||||
return errs.New(errs.CodeForbidden, "not template owner")
|
||||
if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.UpdatePromptTemplate(ctx, id, name, content); err != nil {
|
||||
return err
|
||||
@@ -77,7 +77,7 @@ func (s *templateService) Update(ctx context.Context, userID, id uuid.UUID, name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
func (s *templateService) Delete(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error {
|
||||
t, err := s.repo.GetPromptTemplate(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -85,8 +85,8 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
|
||||
if t == nil {
|
||||
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
|
||||
}
|
||||
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
|
||||
return errs.New(errs.CodeForbidden, "not template owner")
|
||||
if err := checkTemplateWriteAccess(t, userID, isAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeletePromptTemplate(ctx, id); err != nil {
|
||||
return err
|
||||
@@ -100,6 +100,18 @@ func (s *templateService) Delete(ctx context.Context, userID, id uuid.UUID) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTemplateWriteAccess 是 Update/Delete 共用的写权限判定:
|
||||
// user 模板仅 owner;system 模板仅 admin。
|
||||
func checkTemplateWriteAccess(t *PromptTemplate, userID uuid.UUID, isAdmin bool) error {
|
||||
if t.Scope == TemplateScopeUser && (t.OwnerID == nil || *t.OwnerID != userID) {
|
||||
return errs.New(errs.CodeForbidden, "not template owner")
|
||||
}
|
||||
if t.Scope == TemplateScopeSystem && !isAdmin {
|
||||
return errs.New(errs.CodeForbidden, "admin only: system-scoped template")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *templateService) tryAudit(ctx context.Context, e audit.Entry) {
|
||||
if err := s.auditor.Record(ctx, e); err != nil {
|
||||
s.log.Warn("audit record failed", "action", e.Action, "err", err)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
)
|
||||
|
||||
// tplFakeRepo embeds fakeRepo and overrides prompt template methods.
|
||||
type tplFakeRepo struct {
|
||||
*fakeRepo
|
||||
templates map[uuid.UUID]*PromptTemplate
|
||||
}
|
||||
|
||||
func newTplFakeRepo() *tplFakeRepo {
|
||||
return &tplFakeRepo{fakeRepo: newFakeRepo(), templates: map[uuid.UUID]*PromptTemplate{}}
|
||||
}
|
||||
|
||||
func (r *tplFakeRepo) GetPromptTemplate(_ context.Context, id uuid.UUID) (*PromptTemplate, error) {
|
||||
return r.templates[id], nil // 未命中返回 (nil, nil),与真实 repo 语义一致
|
||||
}
|
||||
func (r *tplFakeRepo) UpdatePromptTemplate(_ context.Context, id uuid.UUID, name, content string) error {
|
||||
t, ok := r.templates[id]
|
||||
if !ok {
|
||||
return errs.New(errs.CodeChatTemplateNotFound, "template not found")
|
||||
}
|
||||
t.Name, t.Content = name, content
|
||||
return nil
|
||||
}
|
||||
func (r *tplFakeRepo) DeletePromptTemplate(_ context.Context, id uuid.UUID) error {
|
||||
delete(r.templates, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTplSvc(repo *tplFakeRepo) TemplateService {
|
||||
return NewTemplateService(repo, noopAuditor{}, discardLogger())
|
||||
}
|
||||
|
||||
func addTplFixture(repo *tplFakeRepo, scope TemplateScope, owner *uuid.UUID) *PromptTemplate {
|
||||
t := &PromptTemplate{ID: uuid.New(), Name: "tpl", Content: "c", Scope: scope, OwnerID: owner}
|
||||
repo.templates[t.ID] = t
|
||||
return t
|
||||
}
|
||||
|
||||
func requireForbidden(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
ae, ok := errs.As(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, errs.CodeForbidden, ae.Code)
|
||||
}
|
||||
|
||||
func TestTemplateUpdate_SystemScopeRequiresAdmin(t *testing.T) {
|
||||
repo := newTplFakeRepo()
|
||||
svc := newTplSvc(repo)
|
||||
sys := addTplFixture(repo, TemplateScopeSystem, nil)
|
||||
uid := uuid.New()
|
||||
|
||||
// 非 admin → 拒绝
|
||||
err := svc.Update(context.Background(), uid, false, sys.ID, "x", "y")
|
||||
requireForbidden(t, err)
|
||||
assert.Equal(t, "tpl", repo.templates[sys.ID].Name)
|
||||
|
||||
// admin → 允许
|
||||
require.NoError(t, svc.Update(context.Background(), uid, true, sys.ID, "x", "y"))
|
||||
assert.Equal(t, "x", repo.templates[sys.ID].Name)
|
||||
}
|
||||
|
||||
func TestTemplateDelete_SystemScopeRequiresAdmin(t *testing.T) {
|
||||
repo := newTplFakeRepo()
|
||||
svc := newTplSvc(repo)
|
||||
sys := addTplFixture(repo, TemplateScopeSystem, nil)
|
||||
uid := uuid.New()
|
||||
|
||||
err := svc.Delete(context.Background(), uid, false, sys.ID)
|
||||
requireForbidden(t, err)
|
||||
assert.Contains(t, repo.templates, sys.ID)
|
||||
|
||||
require.NoError(t, svc.Delete(context.Background(), uid, true, sys.ID))
|
||||
assert.NotContains(t, repo.templates, sys.ID)
|
||||
}
|
||||
|
||||
func TestTemplateUpdateDelete_UserScopeOwnerOnly(t *testing.T) {
|
||||
repo := newTplFakeRepo()
|
||||
svc := newTplSvc(repo)
|
||||
owner := uuid.New()
|
||||
tpl := addTplFixture(repo, TemplateScopeUser, &owner)
|
||||
stranger := uuid.New()
|
||||
|
||||
// 非 owner(即使 admin)→ 拒绝,保持既有 owner-only 语义
|
||||
requireForbidden(t, svc.Update(context.Background(), stranger, true, tpl.ID, "x", "y"))
|
||||
requireForbidden(t, svc.Delete(context.Background(), stranger, true, tpl.ID))
|
||||
|
||||
// owner → 允许
|
||||
require.NoError(t, svc.Update(context.Background(), owner, false, tpl.ID, "x", "y"))
|
||||
require.NoError(t, svc.Delete(context.Background(), owner, false, tpl.ID))
|
||||
assert.NotContains(t, repo.templates, tpl.ID)
|
||||
}
|
||||
Reference in New Issue
Block a user