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) }