You've already forked agentic-coding-workflow
255 lines
8.9 KiB
Go
255 lines
8.9 KiB
Go
package project
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
)
|
|
|
|
// 成员/角色 ACL(autonomy roadmap §11)测试。证明:member(editor) 可写、viewer
|
|
// 只读、非成员对 private 仍被拒、owner/admin 行为不变、project-admin 可管理成员、
|
|
// 跨项目成员隔离。
|
|
|
|
func TestMembers_ViewerCanReadNotWrite(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
viewer := uuid.New()
|
|
p := seedProject(t, repo, owner) // private
|
|
_, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewProjectService(repo, nil)
|
|
c := Caller{UserID: viewer}
|
|
|
|
// 读:viewer 可读 private 项目(成员叠加读权限)。
|
|
got, err := svc.Get(context.Background(), c, "demo")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "demo", got.Slug)
|
|
|
|
// 写:viewer 不可写。能读但不能写 → forbidden(其存在性已可见)。
|
|
name := "Renamed"
|
|
_, err = svc.Update(context.Background(), c, "demo", UpdateProjectInput{Name: &name})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeForbidden, ae.Code)
|
|
}
|
|
|
|
func TestMembers_EditorCanWriteRequirements(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
editor := uuid.New()
|
|
p := seedProject(t, repo, owner) // private
|
|
_, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner)
|
|
require.NoError(t, err)
|
|
|
|
reqSvc := NewRequirementService(repo, nil, nil, nil)
|
|
c := Caller{UserID: editor}
|
|
|
|
// member 角色叠加写权限:可在 private 项目创建需求。
|
|
got, err := reqSvc.Create(context.Background(), c, "demo", CreateRequirementInput{Title: "feat"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, "feat", got.Title)
|
|
require.Equal(t, editor, got.OwnerID, "默认 owner 为创建者")
|
|
|
|
// member 也可读。
|
|
_, err = reqSvc.Get(context.Background(), c, "demo", got.Number)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestMembers_ViewerCannotWriteRequirements(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
viewer := uuid.New()
|
|
p := seedProject(t, repo, owner)
|
|
_, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner)
|
|
require.NoError(t, err)
|
|
|
|
reqSvc := NewRequirementService(repo, nil, nil, nil)
|
|
_, err = reqSvc.Create(context.Background(), Caller{UserID: viewer}, "demo", CreateRequirementInput{Title: "x"})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeForbidden, ae.Code, "viewer 能读但不能写 → forbidden")
|
|
|
|
// viewer 仍可读需求列表。
|
|
_, err = reqSvc.List(context.Background(), Caller{UserID: viewer}, "demo", RequirementFilter{})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestMembers_NonMemberDeniedOnPrivate(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner) // private, 无其它成员
|
|
svc := NewProjectService(repo, nil)
|
|
|
|
stranger := Caller{UserID: uuid.New()}
|
|
// 非成员看 private:屏蔽存在性 → not_found(既有防探测语义保持)。
|
|
_, err := svc.Get(context.Background(), stranger, "demo")
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
|
|
// 非成员写:同样 not_found(既有语义)。
|
|
name := "X"
|
|
_, err = svc.Update(context.Background(), stranger, "demo", UpdateProjectInput{Name: &name})
|
|
ae, ok = errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
}
|
|
|
|
func TestMembers_OwnerAndAdminUnchanged(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner) // private, 无 project_members 行(模拟未回填或被移除)
|
|
svc := NewProjectService(repo, nil)
|
|
|
|
// owner 即便没有 project_members 行也可读写(OwnerID 兜底)。
|
|
name := "ByOwner"
|
|
_, err := svc.Update(context.Background(), Caller{UserID: owner}, "demo", UpdateProjectInput{Name: &name})
|
|
require.NoError(t, err)
|
|
|
|
// global-admin 旁路:可读写任意 private 项目。
|
|
name2 := "ByAdmin"
|
|
_, err = svc.Update(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo", UpdateProjectInput{Name: &name2})
|
|
require.NoError(t, err)
|
|
got, err := svc.Get(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ByAdmin", got.Name)
|
|
}
|
|
|
|
func TestMembers_ProjectAdminCanManageMembers(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
padmin := uuid.New()
|
|
target := uuid.New()
|
|
p := seedProject(t, repo, owner)
|
|
_, err := repo.UpsertMember(context.Background(), p.ID, padmin, RoleAdmin, &owner)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewProjectService(repo, nil)
|
|
cAdmin := Caller{UserID: padmin}
|
|
|
|
// project-admin 可新增成员。
|
|
m, err := svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleMember})
|
|
require.NoError(t, err)
|
|
require.Equal(t, RoleMember, m.Role)
|
|
|
|
// project-admin 可改角色(upsert)。
|
|
m, err = svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleViewer})
|
|
require.NoError(t, err)
|
|
require.Equal(t, RoleViewer, m.Role)
|
|
|
|
// project-admin 可列成员。
|
|
members, err := svc.ListMembers(context.Background(), cAdmin, "demo")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, members)
|
|
|
|
// project-admin 可移除成员。
|
|
require.NoError(t, svc.RemoveMember(context.Background(), cAdmin, "demo", target))
|
|
role, err := repo.GetMemberRole(context.Background(), p.ID, target)
|
|
require.NoError(t, err)
|
|
require.Equal(t, RoleNone, role)
|
|
|
|
// 不能移除 owner。
|
|
err = svc.RemoveMember(context.Background(), cAdmin, "demo", owner)
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeForbidden, ae.Code)
|
|
}
|
|
|
|
func TestMembers_NonAdminCannotManageMembers(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
editor := uuid.New()
|
|
p := seedProject(t, repo, owner)
|
|
_, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewProjectService(repo, nil)
|
|
// member(editor) 不可管理成员 → forbidden(能读,但无管理权)。
|
|
_, err = svc.AddMember(context.Background(), Caller{UserID: editor}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeForbidden, ae.Code)
|
|
|
|
// 非成员对 private 管理成员 → not_found 防探测。
|
|
_, err = svc.AddMember(context.Background(), Caller{UserID: uuid.New()}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer})
|
|
ae, ok = errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
}
|
|
|
|
func TestMembers_AddMemberValidatesInput(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
seedProject(t, repo, owner)
|
|
svc := NewProjectService(repo, nil)
|
|
|
|
// 非法角色被拒。
|
|
_, err := svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.New(), Role: Role("superuser")})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
|
|
|
// 空 user_id 被拒。
|
|
_, err = svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.Nil, Role: RoleViewer})
|
|
ae, ok = errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeInvalidInput, ae.Code)
|
|
}
|
|
|
|
func TestMembers_CrossProjectIsolation(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
ownerA := uuid.New()
|
|
ownerB := uuid.New()
|
|
pa := seedProject(t, repo, ownerA) // slug "demo"
|
|
|
|
pb := &Project{ID: uuid.New(), Slug: "other", Name: "Other", Visibility: VisibilityPrivate, OwnerID: ownerB}
|
|
_, err := repo.CreateProject(context.Background(), pb)
|
|
require.NoError(t, err)
|
|
|
|
// 用户是 A 项目的 member,但不是 B 项目成员。
|
|
member := uuid.New()
|
|
_, err = repo.UpsertMember(context.Background(), pa.ID, member, RoleMember, &ownerA)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewProjectService(repo, nil)
|
|
|
|
// A 项目可写。
|
|
nameA := "A-renamed"
|
|
_, err = svc.Update(context.Background(), Caller{UserID: member}, "demo", UpdateProjectInput{Name: &nameA})
|
|
require.NoError(t, err)
|
|
|
|
// B 项目(private,非成员)→ not_found。
|
|
nameB := "B-renamed"
|
|
_, err = svc.Update(context.Background(), Caller{UserID: member}, "other", UpdateProjectInput{Name: &nameB})
|
|
ae, ok := errs.As(err)
|
|
require.True(t, ok)
|
|
require.Equal(t, errs.CodeNotFound, ae.Code)
|
|
}
|
|
|
|
func TestMembers_ListIncludesMembershipProjects(t *testing.T) {
|
|
repo := newFakeRepo()
|
|
owner := uuid.New()
|
|
pa := seedProject(t, repo, owner) // private "demo"
|
|
|
|
viewer := uuid.New()
|
|
_, err := repo.UpsertMember(context.Background(), pa.ID, viewer, RoleViewer, &owner)
|
|
require.NoError(t, err)
|
|
|
|
svc := NewProjectService(repo, nil)
|
|
// viewer 列项目应包含其作为成员的 private 项目。
|
|
got, err := svc.List(context.Background(), Caller{UserID: viewer}, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, "demo", got[0].Slug)
|
|
|
|
// 完全无关用户列不到该 private 项目。
|
|
got, err = svc.List(context.Background(), Caller{UserID: uuid.New()}, false)
|
|
require.NoError(t, err)
|
|
require.Empty(t, got)
|
|
}
|