fix(multi-owner): close permission lifecycle gaps
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectUser;
|
||||
use App\Models\User;
|
||||
use App\Models\WebSocketDialog;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 项目成员管理接口保护测试
|
||||
*/
|
||||
class ProjectMemberProtectionTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private function makeUser(string $email): User
|
||||
{
|
||||
$user = User::createInstance([
|
||||
'email' => $email,
|
||||
'userimg' => '',
|
||||
'nickname' => 'TestUser_' . substr(md5($email), 0, 6),
|
||||
'profession' => '',
|
||||
'password' => md5('123456'),
|
||||
]);
|
||||
$user->save();
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function makeProject(int $ownerUserid, array $memberUserids = []): Project
|
||||
{
|
||||
$allMembers = array_unique(array_merge([$ownerUserid], $memberUserids));
|
||||
$project = Project::createInstance([
|
||||
'name' => 'Test_' . substr(md5(uniqid('', true)), 0, 6),
|
||||
'desc' => '',
|
||||
'userid' => $ownerUserid,
|
||||
'personal' => 0,
|
||||
]);
|
||||
$project->save();
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $ownerUserid,
|
||||
], ['owner' => 1]);
|
||||
foreach ($allMembers as $uid) {
|
||||
if ($uid === $ownerUserid) continue;
|
||||
ProjectUser::updateInsert([
|
||||
'project_id' => $project->id,
|
||||
'userid' => $uid,
|
||||
], ['owner' => 0]);
|
||||
}
|
||||
$dialog = WebSocketDialog::createGroup('Test_dialog', $allMembers, 'project', $ownerUserid);
|
||||
$project->dialog_id = $dialog->id;
|
||||
$project->save();
|
||||
$project->syncDialogUser();
|
||||
return $project->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟 ProjectController::user() 成员同步接口
|
||||
*/
|
||||
private function simulateMemberSync(Project $project, int $callerUserid, array $userids, ?array $deputyUserids): array
|
||||
{
|
||||
$callerRow = ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $callerUserid)->first();
|
||||
if (!$callerRow || !in_array((int)$callerRow->owner, [ProjectUser::OWNER_PRIMARY, ProjectUser::OWNER_DEPUTY], true)) {
|
||||
throw new \RuntimeException('not owner or deputy');
|
||||
}
|
||||
$isPrimary = (int)$callerRow->owner === ProjectUser::OWNER_PRIMARY;
|
||||
$applyDeputy = $isPrimary && $deputyUserids !== null;
|
||||
|
||||
if (count($userids) > 100) {
|
||||
throw new \RuntimeException('over 100 members');
|
||||
}
|
||||
|
||||
// 业务闭环:项目必须且只能有一个主负责人,最终成员列表必须包含该负责人
|
||||
$primaryOwnerIds = ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_PRIMARY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
if (count($primaryOwnerIds) !== 1) {
|
||||
throw new \RuntimeException('项目负责人数据异常,请先修复项目负责人');
|
||||
}
|
||||
$primaryOwnerId = $primaryOwnerIds[0];
|
||||
if (!in_array($primaryOwnerId, $userids, true)) {
|
||||
throw new \RuntimeException('项目成员列表必须包含项目负责人');
|
||||
}
|
||||
// 项目管理员可以管理普通成员,但不能借成员列表移除其他项目管理员
|
||||
if (!$isPrimary) {
|
||||
$currentDeputyIds = ProjectUser::whereProjectId($project->id)
|
||||
->whereOwner(ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')
|
||||
->map(fn($v) => (int)$v)
|
||||
->toArray();
|
||||
if (!empty(array_diff($currentDeputyIds, $userids))) {
|
||||
throw new \RuntimeException('项目管理员不能移除项目负责人或项目管理员');
|
||||
}
|
||||
}
|
||||
|
||||
if ($applyDeputy) {
|
||||
$deputyUserids = array_values(array_unique(array_map('intval', $deputyUserids)));
|
||||
if (!empty(array_diff($deputyUserids, $userids))) {
|
||||
throw new \RuntimeException('deputy must be member');
|
||||
}
|
||||
if (in_array($project->owner_userid, $deputyUserids, true)) {
|
||||
throw new \RuntimeException('primary cannot be deputy');
|
||||
}
|
||||
}
|
||||
|
||||
return AbstractModel::transaction(function () use ($project, $userids, $applyDeputy, $deputyUserids) {
|
||||
$array = [];
|
||||
foreach ($userids as $uid) {
|
||||
if ($project->joinProject($uid)) {
|
||||
$array[] = $uid;
|
||||
}
|
||||
}
|
||||
$deleteRows = ProjectUser::whereProjectId($project->id)->whereNotIn('userid', $array)->get();
|
||||
$deleteUserids = $deleteRows->pluck('userid')->toArray();
|
||||
foreach ($deleteRows as $row) {
|
||||
$row->exitProject();
|
||||
}
|
||||
|
||||
if ($applyDeputy) {
|
||||
$current = ProjectUser::whereProjectId($project->id)
|
||||
->where('owner', ProjectUser::OWNER_DEPUTY)
|
||||
->pluck('userid')->toArray();
|
||||
$toPromote = array_values(array_diff($deputyUserids, $current));
|
||||
$toDemote = array_values(array_diff($current, $deputyUserids));
|
||||
if (!empty($toPromote)) {
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereIn('userid', $toPromote)
|
||||
->where('owner', ProjectUser::OWNER_MEMBER)
|
||||
->change(['owner' => ProjectUser::OWNER_DEPUTY]);
|
||||
}
|
||||
if (!empty($toDemote)) {
|
||||
ProjectUser::whereProjectId($project->id)
|
||||
->whereIn('userid', $toDemote)
|
||||
->where('owner', ProjectUser::OWNER_DEPUTY)
|
||||
->change(['owner' => ProjectUser::OWNER_MEMBER]);
|
||||
}
|
||||
}
|
||||
|
||||
$project->fresh()->syncDialogUser();
|
||||
return $deleteUserids;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:项目负责人不能从成员列表中移除自己
|
||||
*/
|
||||
public function test_primary_owner_cannot_remove_self()
|
||||
{
|
||||
$owner = $this->makeUser('owner@test.local');
|
||||
$member = $this->makeUser('member@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member->userid]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('项目成员列表必须包含项目负责人');
|
||||
|
||||
// 负责人尝试移除自己
|
||||
$this->simulateMemberSync($project, $owner->userid, [$member->userid], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:项目管理员不能通过成员管理接口移除项目负责人
|
||||
*/
|
||||
public function test_deputy_cannot_remove_primary_owner()
|
||||
{
|
||||
$owner = $this->makeUser('owner2@test.local');
|
||||
$deputy = $this->makeUser('deputy2@test.local');
|
||||
$member = $this->makeUser('member2@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
|
||||
// 任命项目管理员
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->update(['owner' => ProjectUser::OWNER_DEPUTY]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('项目成员列表必须包含项目负责人');
|
||||
|
||||
// 项目管理员尝试移除负责人
|
||||
$this->simulateMemberSync($project, $deputy->userid, [$deputy->userid, $member->userid], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:项目负责人可以正常管理普通成员
|
||||
*/
|
||||
public function test_primary_owner_can_manage_regular_members()
|
||||
{
|
||||
$owner = $this->makeUser('owner3@test.local');
|
||||
$member1 = $this->makeUser('member3_1@test.local');
|
||||
$member2 = $this->makeUser('member3_2@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$member1->userid, $member2->userid]);
|
||||
|
||||
// 移除 member2
|
||||
$deleted = $this->simulateMemberSync($project, $owner->userid, [$owner->userid, $member1->userid], []);
|
||||
|
||||
$this->assertContains($member2->userid, $deleted);
|
||||
$this->assertFalse(ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $member2->userid)->exists());
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:项目管理员可以管理普通成员,但不能移除负责人
|
||||
*/
|
||||
public function test_deputy_can_manage_regular_members_but_not_owner()
|
||||
{
|
||||
$owner = $this->makeUser('owner4@test.local');
|
||||
$deputy = $this->makeUser('deputy4@test.local');
|
||||
$member = $this->makeUser('member4@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy->userid, $member->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->where('userid', $deputy->userid)
|
||||
->update(['owner' => ProjectUser::OWNER_DEPUTY]);
|
||||
|
||||
// 项目管理员移除普通成员(应该成功)
|
||||
$deleted = $this->simulateMemberSync($project, $deputy->userid, [$owner->userid, $deputy->userid], null);
|
||||
|
||||
$this->assertContains($member->userid, $deleted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:项目管理员不能移除其他项目管理员(不能借成员管理绕过罢免权限)
|
||||
*/
|
||||
public function test_deputy_cannot_remove_other_deputy()
|
||||
{
|
||||
$owner = $this->makeUser('owner4b@test.local');
|
||||
$deputy1 = $this->makeUser('deputy4b_1@test.local');
|
||||
$deputy2 = $this->makeUser('deputy4b_2@test.local');
|
||||
$member = $this->makeUser('member4b@test.local');
|
||||
$project = $this->makeProject($owner->userid, [$deputy1->userid, $deputy2->userid, $member->userid]);
|
||||
|
||||
ProjectUser::where('project_id', $project->id)
|
||||
->whereIn('userid', [$deputy1->userid, $deputy2->userid])
|
||||
->update(['owner' => ProjectUser::OWNER_DEPUTY]);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('项目管理员不能移除项目负责人或项目管理员');
|
||||
|
||||
// deputy1 尝试移除 deputy2
|
||||
$this->simulateMemberSync($project, $deputy1->userid, [$owner->userid, $deputy1->userid, $member->userid], null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试:空成员列表被拒绝(因为必须包含负责人)
|
||||
*/
|
||||
public function test_empty_member_list_rejected()
|
||||
{
|
||||
$owner = $this->makeUser('owner5@test.local');
|
||||
$project = $this->makeProject($owner->userid);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('项目成员列表必须包含项目负责人');
|
||||
|
||||
$this->simulateMemberSync($project, $owner->userid, [], []);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user