diff --git a/src/Event/AfterGithubIdentify.php b/src/Event/AfterGithubIdentify.php index 4038f587..6a45add5 100644 --- a/src/Event/AfterGithubIdentify.php +++ b/src/Event/AfterGithubIdentify.php @@ -5,11 +5,11 @@ use ADmad\SocialAuth\Middleware\SocialAuthMiddleware; use App\Model\Entity\User; +use App\Model\Enum\UserRole; use Cake\Event\EventInterface; use Cake\Event\EventListenerInterface; use Cake\Http\Client; use Cake\ORM\Locator\LocatorAwareTrait; -use Cake\Utility\Hash; class AfterGithubIdentify implements EventListenerInterface { @@ -49,21 +49,17 @@ public function afterIdentify(EventInterface $event, User $user): void ], ]); - $response = $http->get('https://api.github.com/user/orgs'); - if (!$response->isOk()) { + $response = $http->get('https://api.github.com/user/memberships/orgs/cakephp'); + if (!$response->isOk() && $response->getStatusCode() !== 404) { return; } - $data = $response->getJson(); - $orgs = Hash::extract($data, '{n}.login'); - - $isCakePHPDev = false; - if (in_array('cakephp', $orgs, true)) { - $isCakePHPDev = true; - } + $isCakePHPDev = $response->isOk() && ($response->getJson()['state'] ?? null) === 'active'; $usersTable = $this->getTableLocator()->get('Users'); - $user = $usersTable->patchEntity($user, ['is_cakephp_dev' => $isCakePHPDev]); - $usersTable->save($user); + // Organization membership is authoritative, even for the last administrator. + $user->set('is_cakephp_dev', $isCakePHPDev); + $user->set('role', $isCakePHPDev ? UserRole::Admin : UserRole::User); + $usersTable->saveOrFail($user); } } diff --git a/tests/TestCase/Event/AfterGithubIdentifyTest.php b/tests/TestCase/Event/AfterGithubIdentifyTest.php new file mode 100644 index 00000000..dc24dcd7 --- /dev/null +++ b/tests/TestCase/Event/AfterGithubIdentifyTest.php @@ -0,0 +1,79 @@ + + */ + #[Override] + protected array $fixtures = ['app.Users']; + + /** + * Login synchronizes both the persisted account and the identified entity. + */ + #[DataProvider('membershipProvider')] + public function testSynchronizesMembership( + int $id, + int $status, + string $body, + UserRole $expectedRole, + bool $expectedDeveloper, + ): void { + $users = $this->fetchTable(UsersTable::class); + $user = $users->get($id); + $user->set('is_cakephp_dev', $user->role === UserRole::Admin); + $users->saveOrFail($user); + $user->set('social_profile', new SocialProfile([ + 'access_token' => new AccessToken(['access_token' => 'test-token']), + ])); + $this->mockClientGet( + 'https://api.github.com/user/memberships/orgs/cakephp', + $this->newClientResponse($status, ['Content-Type: application/json'], $body), + ); + + $listener = new AfterGithubIdentify(); + $listener->setTableLocator($this->getTableLocator()); + $listener->afterIdentify(new Event(SocialAuthMiddleware::EVENT_AFTER_IDENTIFY), $user); + + $this->assertSame($expectedRole, $user->role); + $this->assertSame($expectedDeveloper, $user->is_cakephp_dev); + $saved = $users->get($id); + $this->assertSame($expectedRole, $saved->role); + $this->assertSame($expectedDeveloper, $saved->is_cakephp_dev); + $this->assertTrue($saved->is_active); + } + + /** + * @return array + */ + public static function membershipProvider(): array + { + return [ + 'promote member' => [2, 200, '{"state":"active","role":"member"}', UserRole::Admin, true], + 'retain member admin' => [1, 200, '{"state":"active"}', UserRole::Admin, true], + 'demote last admin' => [1, 404, '{"message":"Not Found"}', UserRole::User, false], + 'retain nonmember user' => [2, 404, '{"message":"Not Found"}', UserRole::User, false], + 'pending invitation is not membership' => [1, 200, '{"state":"pending"}', UserRole::User, false], + 'API outage preserves access' => [1, 503, '{}', UserRole::Admin, true], + 'forbidden preserves access' => [1, 403, '{}', UserRole::Admin, true], + 'failed request cannot promote' => [2, 401, '{}', UserRole::User, false], + ]; + } +}