Skip to content

Commit 3e4e4dc

Browse files
gloriafolaronclaude
andcommitted
fix(projects): check membership, not access, in addUserToProject()
isUserAssignedToProject() answers "can this user reach the project", and it returns true for every admin and owner without consulting zp_relationuserproject at all. Using it as the idempotence check made addUserToProject() a permanent no-op for exactly those users: an admin could never be placed on a project team, and the caller was told "already a member" about someone holding no relation row. Found while wiring the first consumer — allocating an owner to a project in PgmPro Resource Allocation reported success, emitted no toast, and left the team unchanged. isUserMemberOfProject() is the same query without the role short-circuit, which is what writing a membership should be gated on. Adds a regression test pinning the distinction: a repository where isUserAssignedToProject returns true but isUserMemberOfProject returns false must still insert. 42 tests / 146 assertions green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYSPNgLdUwEnxgYqTxMc85
1 parent 3ade354 commit 3e4e4dc

2 files changed

Lines changed: 37 additions & 4 deletions

File tree

app/Domain/Projects/Services/Projects.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2379,7 +2379,15 @@ public function addUserToProject(int $userId, int $projectId, string $projectRol
23792379
return false;
23802380
}
23812381

2382-
if ($this->projectRepository->isUserAssignedToProject($userId, $projectId)) {
2382+
// isUserMemberOfProject, NOT isUserAssignedToProject: the latter
2383+
// answers "can this user reach the project", which is true for
2384+
// every admin and owner regardless of any relation row. Using it
2385+
// here would make the method a silent no-op for exactly those
2386+
// users — an admin could never be added to a project team, and
2387+
// callers would get false ("already a member") for someone who
2388+
// isn't on the team at all. Membership is what we're writing, so
2389+
// membership is what we check.
2390+
if ($this->projectRepository->isUserMemberOfProject($userId, $projectId)) {
23832391
return false;
23842392
}
23852393

tests/Unit/app/Domain/Projects/Services/ProjectsServiceTest.php

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,7 @@ public function test_add_user_to_project_inserts_when_not_a_member(): void
706706
{
707707
$added = [];
708708
$repo = $this->makeEmpty(ProjectRepository::class, [
709-
'isUserAssignedToProject' => fn () => false,
709+
'isUserMemberOfProject' => fn () => false,
710710
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
711711
$added[] = [$userId, $projectId, $role];
712712
},
@@ -721,6 +721,31 @@ public function test_add_user_to_project_inserts_when_not_a_member(): void
721721
$this->assertSame([[7, 42, 'contributor']], $added);
722722
}
723723

724+
/**
725+
* Membership is not access. isUserAssignedToProject() returns true for
726+
* every admin and owner whether or not a relation row exists, so using
727+
* it as the idempotence check would make this method a permanent
728+
* no-op for exactly those users: an admin could never be put on a
729+
* project team, and the caller would be told "already a member" about
730+
* someone who is not on the team at all.
731+
*/
732+
public function test_add_user_to_project_adds_admin_who_has_access_but_no_membership(): void
733+
{
734+
$added = [];
735+
$repo = $this->makeEmpty(ProjectRepository::class, [
736+
// An admin: reaches every project...
737+
'isUserAssignedToProject' => fn () => true,
738+
// ...but holds no relation row for this one.
739+
'isUserMemberOfProject' => fn () => false,
740+
'addProjectRelation' => function ($userId, $projectId, $role) use (&$added) {
741+
$added[] = [$userId, $projectId, $role];
742+
},
743+
]);
744+
745+
$this->assertTrue($this->makeService($repo)->addUserToProject(7, 42));
746+
$this->assertSame([[7, 42, '']], $added, 'access must not be mistaken for membership');
747+
}
748+
724749
/**
725750
* Idempotence guard. zp_relationuserproject has no unique index on
726751
* (userId, projectId), so a blind insert would duplicate the row and
@@ -730,7 +755,7 @@ public function test_add_user_to_project_is_idempotent_for_existing_member(): vo
730755
{
731756
$addCalls = 0;
732757
$repo = $this->makeEmpty(ProjectRepository::class, [
733-
'isUserAssignedToProject' => fn () => true,
758+
'isUserMemberOfProject' => fn () => true,
734759
'addProjectRelation' => function () use (&$addCalls) {
735760
$addCalls++;
736761
},
@@ -750,7 +775,7 @@ public function test_add_user_to_project_rejects_invalid_ids(): void
750775
{
751776
$touched = 0;
752777
$repo = $this->makeEmpty(ProjectRepository::class, [
753-
'isUserAssignedToProject' => function () use (&$touched) {
778+
'isUserMemberOfProject' => function () use (&$touched) {
754779
$touched++;
755780

756781
return false;

0 commit comments

Comments
 (0)