diff --git a/api/components/OAuth2/Storage/ClientStorage.php b/api/components/OAuth2/Storage/ClientStorage.php index 9a339f0..d5979e2 100644 --- a/api/components/OAuth2/Storage/ClientStorage.php +++ b/api/components/OAuth2/Storage/ClientStorage.php @@ -18,14 +18,12 @@ class ClientStorage extends AbstractStorage implements ClientInterface { * @inheritdoc */ public function get($clientId, $clientSecret = null, $redirectUri = null, $grantType = null) { - $query = OauthClient::find()->andWhere(['id' => $clientId]); - if ($clientSecret !== null) { - $query->andWhere(['secret' => $clientSecret]); + $model = $this->findClient($clientId); + if ($model === null) { + return null; } - /** @var OauthClient|null $model */ - $model = $query->one(); - if ($model === null) { + if ($clientSecret !== null && $clientSecret !== $model->secret) { return null; } @@ -60,8 +58,7 @@ class ClientStorage extends AbstractStorage implements ClientInterface { throw new \ErrorException('This module assumes that $session typeof ' . SessionEntity::class); } - /** @var OauthClient|null $model */ - $model = OauthClient::findOne($session->getClientId()); + $model = $this->findClient($session->getClientId()); if ($model === null) { return null; } @@ -80,4 +77,8 @@ class ClientStorage extends AbstractStorage implements ClientInterface { return $entity; } + private function findClient(string $clientId): ?OauthClient { + return OauthClient::findOne($clientId); + } + } diff --git a/api/config/config.php b/api/config/config.php index 8969ef5..5637f01 100644 --- a/api/config/config.php +++ b/api/config/config.php @@ -86,5 +86,6 @@ return [ 'mojang' => api\modules\mojang\Module::class, 'internal' => api\modules\internal\Module::class, 'accounts' => api\modules\accounts\Module::class, + 'oauth' => api\modules\oauth\Module::class, ], ]; diff --git a/api/config/routes.php b/api/config/routes.php index aa624a5..9a385ef 100644 --- a/api/config/routes.php +++ b/api/config/routes.php @@ -3,8 +3,17 @@ * @var array $params */ return [ - '/oauth2/v1/' => 'oauth/', + // Oauth module routes + '/oauth2/v1/' => 'oauth/authorization/', + 'POST /v1/oauth2/' => 'oauth/clients/create', + 'GET /v1/oauth2/' => 'oauth/clients/get', + 'PUT /v1/oauth2/' => 'oauth/clients/update', + 'DELETE /v1/oauth2/' => 'oauth/clients/delete', + 'POST /v1/oauth2//reset' => 'oauth/clients/reset', + 'GET /v1/accounts//oauth2/clients' => 'oauth/clients/get-per-account', + '/account/v1/info' => 'oauth/identity/index', + // Accounts module routes 'GET /v1/accounts/' => 'accounts/default/get', 'GET /v1/accounts//two-factor-auth' => 'accounts/default/get-two-factor-auth-credentials', 'POST /v1/accounts//two-factor-auth' => 'accounts/default/enable-two-factor-auth', @@ -13,6 +22,7 @@ return [ 'DELETE /v1/accounts//ban' => 'accounts/default/pardon', '/v1/accounts//' => 'accounts/default/', + // Legacy accounts endpoints. It should be removed after frontend will be updated. 'GET /accounts/current' => 'accounts/default/get', 'POST /accounts/change-username' => 'accounts/default/username', 'POST /accounts/change-password' => 'accounts/default/password', @@ -25,14 +35,14 @@ return [ 'DELETE /two-factor-auth' => 'accounts/default/disable-two-factor-auth', 'POST /accounts/change-lang' => 'accounts/default/language', - '/account/v1/info' => 'identity-info/index', - + // Session server module routes '/minecraft/session/join' => 'session/session/join', '/minecraft/session/legacy/join' => 'session/session/join-legacy', '/minecraft/session/hasJoined' => 'session/session/has-joined', '/minecraft/session/legacy/hasJoined' => 'session/session/has-joined-legacy', '/minecraft/session/profile/' => 'session/session/profile', + // Mojang API module routes '/mojang/profiles/' => 'mojang/api/uuid-by-username', '/mojang/profiles//names' => 'mojang/api/usernames-by-uuid', 'POST /mojang/profiles' => 'mojang/api/uuids-by-usernames', diff --git a/api/modules/oauth/Module.php b/api/modules/oauth/Module.php new file mode 100644 index 0000000..2ced001 --- /dev/null +++ b/api/modules/oauth/Module.php @@ -0,0 +1,10 @@ + [ 'only' => ['complete'], ], diff --git a/api/modules/oauth/controllers/ClientsController.php b/api/modules/oauth/controllers/ClientsController.php new file mode 100644 index 0000000..3e7960a --- /dev/null +++ b/api/modules/oauth/controllers/ClientsController.php @@ -0,0 +1,192 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + [ + 'actions' => ['create'], + 'allow' => true, + 'permissions' => [P::CREATE_OAUTH_CLIENTS], + ], + [ + 'actions' => ['update', 'delete', 'reset'], + 'allow' => true, + 'permissions' => [P::MANAGE_OAUTH_CLIENTS], + 'roleParams' => function() { + return [ + 'clientId' => Yii::$app->request->get('clientId'), + ]; + }, + ], + [ + 'actions' => ['get'], + 'allow' => true, + 'permissions' => [P::VIEW_OAUTH_CLIENTS], + 'roleParams' => function() { + return [ + 'clientId' => Yii::$app->request->get('clientId'), + ]; + }, + ], + [ + 'actions' => ['get-per-account'], + 'allow' => true, + 'permissions' => [P::VIEW_OAUTH_CLIENTS], + 'roleParams' => function() { + return [ + 'accountId' => Yii::$app->request->get('accountId'), + ]; + }, + ], + ], + ], + ]); + } + + public function actionGet(string $clientId): array { + return $this->formatClient($this->findOauthClient($clientId)); + } + + public function actionCreate(string $type): array { + $account = Yii::$app->user->identity->getAccount(); + if ($account === null) { + throw new ThisShouldNotHappenException('This form should not to be executed without associated account'); + } + + $client = new OauthClient(); + $client->account_id = $account->id; + $client->type = $type; + $requestModel = $this->createForm($client); + $requestModel->load(Yii::$app->request->post()); + $form = new OauthClientForm($client); + if (!$form->save($requestModel)) { + return [ + 'success' => false, + 'errors' => $requestModel->getValidationErrors(), + ]; + } + + return [ + 'success' => true, + 'data' => $this->formatClient($client), + ]; + } + + public function actionUpdate(string $clientId): array { + $client = $this->findOauthClient($clientId); + $requestModel = $this->createForm($client); + $requestModel->load(Yii::$app->request->post()); + $form = new OauthClientForm($client); + if (!$form->save($requestModel)) { + return [ + 'success' => false, + 'errors' => $requestModel->getValidationErrors(), + ]; + } + + return [ + 'success' => true, + 'data' => $this->formatClient($client), + ]; + } + + public function actionDelete(string $clientId): array { + $client = $this->findOauthClient($clientId); + (new OauthClientForm($client))->delete(); + + return [ + 'success' => true, + ]; + } + + public function actionReset(string $clientId, string $regenerateSecret = null): array { + $client = $this->findOauthClient($clientId); + $form = new OauthClientForm($client); + $form->reset($regenerateSecret !== null); + + return [ + 'success' => true, + 'data' => $this->formatClient($client), + ]; + } + + public function actionGetPerAccount(int $accountId): array { + /** @var Account|null $account */ + $account = Account::findOne(['id' => $accountId]); + if ($account === null) { + throw new NotFoundHttpException(); + } + + $clients = $account->oauthClients; + $result = array_map(function(OauthClient $client) { + return $this->formatClient($client); + }, $clients); + + return $result; + } + + private function formatClient(OauthClient $client): array { + $result = [ + 'clientId' => $client->id, + 'clientSecret' => $client->secret, + 'type' => $client->type, + 'name' => $client->name, + 'websiteUrl' => $client->website_url, + 'createdAt' => $client->created_at, + 'countUsers' => (int)$client->getSessions()->count(), + ]; + + switch ($client->type) { + case OauthClient::TYPE_APPLICATION: + $result['description'] = $client->description; + $result['redirectUri'] = $client->redirect_uri; + break; + case OauthClient::TYPE_MINECRAFT_SERVER: + $result['minecraftServerIp'] = $client->minecraft_server_ip; + break; + } + + return $result; + } + + private function createForm(OauthClient $client): OauthClientTypeForm { + try { + $model = OauthClientFormFactory::create($client); + } catch (UnsupportedOauthClientType $e) { + Yii::warning('Someone tried use ' . $client->type . ' type of oauth form.'); + throw new NotFoundHttpException(null, 0, $e); + } + + return $model; + } + + private function findOauthClient(string $clientId): OauthClient { + /** @var OauthClient|null $client */ + $client = OauthClient::findOne($clientId); + if ($client === null) { + throw new NotFoundHttpException(); + } + + return $client; + } + +} diff --git a/api/controllers/IdentityInfoController.php b/api/modules/oauth/controllers/IdentityController.php similarity index 75% rename from api/controllers/IdentityInfoController.php rename to api/modules/oauth/controllers/IdentityController.php index bbc73e9..5f0f952 100644 --- a/api/controllers/IdentityInfoController.php +++ b/api/modules/oauth/controllers/IdentityController.php @@ -1,16 +1,17 @@ [ 'class' => AccessControl::class, 'rules' => [ @@ -32,7 +33,7 @@ class IdentityInfoController extends Controller { public function actionIndex(): array { /** @noinspection NullPointerExceptionInspection */ - return (new OauthAccountInfo(Yii::$app->user->getIdentity()->getAccount()))->info(); + return (new IdentityInfo(Yii::$app->user->getIdentity()->getAccount()))->info(); } } diff --git a/api/modules/oauth/exceptions/InvalidOauthClientState.php b/api/modules/oauth/exceptions/InvalidOauthClientState.php new file mode 100644 index 0000000..9a0db4d --- /dev/null +++ b/api/modules/oauth/exceptions/InvalidOauthClientState.php @@ -0,0 +1,8 @@ +type = $type; + } + + public function getType(): string { + return $this->type; + } + +} diff --git a/api/modules/oauth/models/ApplicationType.php b/api/modules/oauth/models/ApplicationType.php new file mode 100644 index 0000000..244bf7c --- /dev/null +++ b/api/modules/oauth/models/ApplicationType.php @@ -0,0 +1,30 @@ + E::REDIRECT_URI_REQUIRED], + ['redirectUri', 'url', 'validSchemes' => ['[\w]+'], 'message' => E::REDIRECT_URI_INVALID], + ['description', 'string'], + ]); + } + + public function applyToClient(OauthClient $client): void { + parent::applyToClient($client); + $client->description = $this->description; + $client->redirect_uri = $this->redirectUri; + } + +} diff --git a/api/modules/oauth/models/BaseOauthClientType.php b/api/modules/oauth/models/BaseOauthClientType.php new file mode 100644 index 0000000..72a97ec --- /dev/null +++ b/api/modules/oauth/models/BaseOauthClientType.php @@ -0,0 +1,40 @@ + E::NAME_REQUIRED], + ['websiteUrl', 'url', 'message' => E::WEBSITE_URL_INVALID], + ]; + } + + public function load($data, $formName = null): bool { + return parent::load($data, $formName); + } + + public function validate($attributeNames = null, $clearErrors = true): bool { + return parent::validate($attributeNames, $clearErrors); + } + + public function getValidationErrors(): array { + return $this->getFirstErrors(); + } + + public function applyToClient(OauthClient $client): void { + $client->name = $this->name; + $client->website_url = $this->websiteUrl; + } + +} diff --git a/api/models/OauthAccountInfo.php b/api/modules/oauth/models/IdentityInfo.php similarity index 85% rename from api/models/OauthAccountInfo.php rename to api/modules/oauth/models/IdentityInfo.php index 55a6f3b..24ff32d 100644 --- a/api/models/OauthAccountInfo.php +++ b/api/modules/oauth/models/IdentityInfo.php @@ -1,11 +1,13 @@ E::MINECRAFT_SERVER_IP_INVALID], + ]); + } + + public function applyToClient(OauthClient $client): void { + parent::applyToClient($client); + $client->minecraft_server_ip = $this->minecraftServerIp; + } + +} diff --git a/api/modules/oauth/models/OauthClientForm.php b/api/modules/oauth/models/OauthClientForm.php new file mode 100644 index 0000000..8321a22 --- /dev/null +++ b/api/modules/oauth/models/OauthClientForm.php @@ -0,0 +1,96 @@ +type === null) { + throw new InvalidOauthClientState('client\'s type field must be set'); + } + + $this->client = $client; + } + + public function getClient(): OauthClient { + return $this->client; + } + + public function save(OauthClientTypeForm $form): bool { + if (!$form->validate()) { + return false; + } + + $client = $this->getClient(); + $form->applyToClient($client); + + if ($client->isNewRecord) { + $baseId = $id = substr(Inflector::slug($client->name), 0, 250); + $i = 0; + while ($this->isClientExists($id)) { + $id = $baseId . ++$i; + } + + $client->id = $id; + $client->generateSecret(); + } + + if (!$client->save()) { + throw new ThisShouldNotHappenException('Cannot save oauth client'); + } + + return true; + } + + public function delete(): bool { + $transaction = Yii::$app->db->beginTransaction(); + + $client = $this->client; + $client->is_deleted = true; + if (!$client->save()) { + throw new ThisShouldNotHappenException('Cannot update oauth client'); + } + + Yii::$app->queue->push(ClearOauthSessions::createFromOauthClient($client)); + + $transaction->commit(); + + return true; + } + + public function reset(bool $regenerateSecret = false): bool { + $transaction = Yii::$app->db->beginTransaction(); + + $client = $this->client; + if ($regenerateSecret) { + $client->generateSecret(); + if (!$client->save()) { + throw new ThisShouldNotHappenException('Cannot update oauth client'); + } + } + + Yii::$app->queue->push(ClearOauthSessions::createFromOauthClient($client, time())); + + $transaction->commit(); + + return true; + } + + protected function isClientExists(string $id): bool { + return OauthClient::find()->andWhere(['id' => $id])->exists(); + } + +} diff --git a/api/modules/oauth/models/OauthClientFormFactory.php b/api/modules/oauth/models/OauthClientFormFactory.php new file mode 100644 index 0000000..62dfeed --- /dev/null +++ b/api/modules/oauth/models/OauthClientFormFactory.php @@ -0,0 +1,37 @@ +type) { + case OauthClient::TYPE_APPLICATION: + return new ApplicationType([ + 'name' => $client->name, + 'websiteUrl' => $client->website_url, + 'description' => $client->description, + 'redirectUri' => $client->redirect_uri, + ]); + case OauthClient::TYPE_MINECRAFT_SERVER: + return new MinecraftServerType([ + 'name' => $client->name, + 'websiteUrl' => $client->website_url, + 'minecraftServerIp' => $client->minecraft_server_ip, + ]); + } + + throw new UnsupportedOauthClientType($client->type); + } + +} diff --git a/api/modules/oauth/models/OauthClientTypeForm.php b/api/modules/oauth/models/OauthClientTypeForm.php new file mode 100644 index 0000000..58d90ef --- /dev/null +++ b/api/modules/oauth/models/OauthClientTypeForm.php @@ -0,0 +1,18 @@ +getAuthorizationCodeGrant()->checkAuthorizeParams(); $client = $authParams->getClient(); - /** @var \common\models\OauthClient $clientModel */ - $clientModel = OauthClient::findOne($client->getId()); + /** @var OauthClient $clientModel */ + $clientModel = $this->findClient($client->getId()); $response = $this->buildSuccessResponse( Yii::$app->request->getQueryParams(), $clientModel, @@ -90,9 +90,10 @@ class OauthProcess { Yii::$app->statsd->inc('oauth.complete.attempt'); $grant = $this->getAuthorizationCodeGrant(); $authParams = $grant->checkAuthorizeParams(); + /** @var Account $account */ $account = Yii::$app->user->identity->getAccount(); /** @var \common\models\OauthClient $clientModel */ - $clientModel = OauthClient::findOne($authParams->getClient()->getId()); + $clientModel = $this->findClient($authParams->getClient()->getId()); if (!$this->canAutoApprove($account, $clientModel, $authParams)) { Yii::$app->statsd->inc('oauth.complete.approve_required'); @@ -164,6 +165,10 @@ class OauthProcess { return $response; } + private function findClient(string $clientId): ?OauthClient { + return OauthClient::findOne($clientId); + } + /** * Метод проверяет, может ли текущий пользователь быть автоматически авторизован * для указанного клиента без запроса доступа к необходимому списку прав diff --git a/api/modules/session/filters/RateLimiter.php b/api/modules/session/filters/RateLimiter.php index 12cd5b7..09b9efe 100644 --- a/api/modules/session/filters/RateLimiter.php +++ b/api/modules/session/filters/RateLimiter.php @@ -85,7 +85,7 @@ class RateLimiter extends \yii\filters\RateLimiter { } if ($this->server === null) { - /** @var OauthClient $server */ + /** @var OauthClient|null $server */ $this->server = OauthClient::findOne($serverId); // TODO: убедится, что это сервер if ($this->server === null) { diff --git a/common/helpers/Error.php b/common/helpers/Error.php index d31a9f1..8e34ba9 100644 --- a/common/helpers/Error.php +++ b/common/helpers/Error.php @@ -60,4 +60,13 @@ final class Error { const OTP_ALREADY_ENABLED = 'error.otp_already_enabled'; const OTP_NOT_ENABLED = 'error.otp_not_enabled'; + const NAME_REQUIRED = 'error.name_required'; + + const REDIRECT_URI_REQUIRED = 'error.redirectUri_required'; + const REDIRECT_URI_INVALID = 'error.redirectUri_invalid'; + + const WEBSITE_URL_INVALID = 'error.websiteUrl_invalid'; + + const MINECRAFT_SERVER_IP_INVALID = 'error.minecraftServerIp_invalid'; + } diff --git a/common/models/Account.php b/common/models/Account.php index 46eddba..a923a63 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -34,6 +34,7 @@ use const common\LATEST_RULES_VERSION; * Отношения: * @property EmailActivation[] $emailActivations * @property OauthSession[] $oauthSessions + * @property OauthClient[] $oauthClients * @property UsernameHistory[] $usernameHistory * @property AccountSession[] $sessions * @property MinecraftAccessKey[] $minecraftAccessKeys @@ -93,6 +94,11 @@ class Account extends ActiveRecord { return $this->hasMany(OauthSession::class, ['owner_id' => 'id'])->andWhere(['owner_type' => 'user']); } + public function getOauthClients(): OauthClientQuery { + /** @noinspection PhpIncompatibleReturnTypeInspection */ + return $this->hasMany(OauthClient::class, ['account_id' => 'id']); + } + public function getUsernameHistory(): ActiveQuery { return $this->hasMany(UsernameHistory::class, ['account_id' => 'id']); } diff --git a/common/models/OauthClient.php b/common/models/OauthClient.php index 58b444b..aabccc7 100644 --- a/common/models/OauthClient.php +++ b/common/models/OauthClient.php @@ -1,48 +1,62 @@ function(self $model) { - return $model->isNewRecord; - }], - [['id'], 'unique', 'when' => function(self $model) { - return $model->isNewRecord; - }], - [['name', 'description'], 'required'], - [['name', 'description'], 'string', 'max' => 255], + [ + 'class' => TimestampBehavior::class, + 'updatedAtAttribute' => false, + ], ]; } - public function getAccount() { + public function generateSecret(): void { + $this->secret = Yii::$app->security->generateRandomString(64); + } + + public function getAccount(): ActiveQuery { return $this->hasOne(Account::class, ['id' => 'account_id']); } - public function getSessions() { + public function getSessions(): ActiveQuery { return $this->hasMany(OauthSession::class, ['client_id' => 'id']); } + public static function find(): OauthClientQuery { + return Yii::createObject(OauthClientQuery::class, [static::class]); + } + } diff --git a/common/models/OauthClientQuery.php b/common/models/OauthClientQuery.php new file mode 100644 index 0000000..cc981b9 --- /dev/null +++ b/common/models/OauthClientQuery.php @@ -0,0 +1,34 @@ +showDeleted = true; + return $this; + } + + public function onlyDeleted(): self { + $this->showDeleted = true; + return $this->andWhere(['is_deleted' => true]); + } + + public function createCommand($db = null): Command { + if ($this->showDeleted === false) { + $this->andWhere(['is_deleted' => false]); + } + + return parent::createCommand($db); + } + +} diff --git a/common/models/OauthSession.php b/common/models/OauthSession.php index acb3049..e654dcd 100644 --- a/common/models/OauthSession.php +++ b/common/models/OauthSession.php @@ -4,6 +4,7 @@ namespace common\models; use common\components\Redis\Set; use Yii; use yii\base\NotSupportedException; +use yii\behaviors\TimestampBehavior; use yii\db\ActiveQuery; use yii\db\ActiveRecord; @@ -14,6 +15,7 @@ use yii\db\ActiveRecord; * @property string|null $owner_id * @property string $client_id * @property string $client_redirect_uri + * @property integer $created_at * * Отношения * @property OauthClient $client @@ -26,6 +28,15 @@ class OauthSession extends ActiveRecord { return '{{%oauth_sessions}}'; } + public function behaviors() { + return [ + [ + 'class' => TimestampBehavior::class, + 'updatedAtAttribute' => false, + ], + ]; + } + public function getClient(): ActiveQuery { return $this->hasOne(OauthClient::class, ['id' => 'client_id']); } diff --git a/common/rbac/Permissions.php b/common/rbac/Permissions.php index e86c1e0..1b0b710 100644 --- a/common/rbac/Permissions.php +++ b/common/rbac/Permissions.php @@ -12,6 +12,9 @@ final class Permissions { public const MANAGE_TWO_FACTOR_AUTH = 'manage_two_factor_auth'; public const BLOCK_ACCOUNT = 'block_account'; public const COMPLETE_OAUTH_FLOW = 'complete_oauth_flow'; + public const CREATE_OAUTH_CLIENTS = 'create_oauth_clients'; + public const VIEW_OAUTH_CLIENTS = 'view_oauth_clients'; + public const MANAGE_OAUTH_CLIENTS = 'manage_oauth_clients'; // Personal level controller permissions public const OBTAIN_OWN_ACCOUNT_INFO = 'obtain_own_account_info'; @@ -23,6 +26,8 @@ final class Permissions { public const CHANGE_OWN_ACCOUNT_EMAIL = 'change_own_account_email'; public const MANAGE_OWN_TWO_FACTOR_AUTH = 'manage_own_two_factor_auth'; public const MINECRAFT_SERVER_SESSION = 'minecraft_server_session'; + public const VIEW_OWN_OAUTH_CLIENTS = 'view_own_oauth_clients'; + public const MANAGE_OWN_OAUTH_CLIENTS = 'manage_own_oauth_clients'; // Data permissions public const OBTAIN_ACCOUNT_EMAIL = 'obtain_account_email'; diff --git a/common/rbac/rules/AccountOwner.php b/common/rbac/rules/AccountOwner.php index d5be749..a16b1a0 100644 --- a/common/rbac/rules/AccountOwner.php +++ b/common/rbac/rules/AccountOwner.php @@ -3,7 +3,6 @@ namespace common\rbac\rules; use common\models\Account; use Yii; -use yii\base\InvalidParamException; use yii\rbac\Rule; class AccountOwner extends Rule { @@ -26,7 +25,7 @@ class AccountOwner extends Rule { public function execute($accessToken, $item, $params): bool { $accountId = $params['accountId'] ?? null; if ($accountId === null) { - throw new InvalidParamException('params don\'t contain required key: accountId'); + return false; } $identity = Yii::$app->user->findIdentityByAccessToken($accessToken); diff --git a/common/rbac/rules/OauthClientOwner.php b/common/rbac/rules/OauthClientOwner.php new file mode 100644 index 0000000..e303743 --- /dev/null +++ b/common/rbac/rules/OauthClientOwner.php @@ -0,0 +1,61 @@ +name === P::VIEW_OWN_OAUTH_CLIENTS) { + return (new AccountOwner())->execute($accessToken, $item, ['accountId' => $accountId]); + } + + $clientId = $params['clientId'] ?? null; + if ($clientId === null) { + return false; + } + + /** @var OauthClient|null $client */ + $client = OauthClient::findOne($clientId); + if ($client === null) { + return false; + } + + $identity = Yii::$app->user->findIdentityByAccessToken($accessToken); + if ($identity === null) { + return false; + } + + $account = $identity->getAccount(); + if ($account === null) { + return false; + } + + if ($account->id !== $client->account_id) { + return false; + } + + return true; + } + +} diff --git a/common/tasks/ClearOauthSessions.php b/common/tasks/ClearOauthSessions.php new file mode 100644 index 0000000..4a54733 --- /dev/null +++ b/common/tasks/ClearOauthSessions.php @@ -0,0 +1,69 @@ +clientId = $client->id; + if ($notSince !== null) { + $result->notSince = $notSince; + } + + return $result; + } + + public function getTtr(): int { + return 60/*sec*/ * 5/*min*/; + } + + public function canRetry($attempt, $error): bool { + return true; + } + + /** + * @param \yii\queue\Queue $queue which pushed and is handling the job + * + * @throws \Exception + * @throws \Throwable + * @throws \yii\db\StaleObjectException + */ + public function execute($queue): void { + Yii::$app->statsd->inc('queue.clearOauthSessions.attempt'); + /** @var OauthClient|null $client */ + $client = OauthClient::find() + ->includeDeleted() + ->andWhere(['id' => $this->clientId]) + ->one(); + if ($client === null) { + return; + } + + $sessionsQuery = $client->getSessions(); + if ($this->notSince !== null) { + $sessionsQuery->andWhere(['<=', 'created_at', $this->notSince]); + } + + foreach ($sessionsQuery->each(100, Yii::$app->unbufferedDb) as $session) { + /** @var \common\models\OauthSession $session */ + $session->delete(); + } + } + +} diff --git a/common/validators/MinecraftServerAddressValidator.php b/common/validators/MinecraftServerAddressValidator.php new file mode 100644 index 0000000..2cf98bb --- /dev/null +++ b/common/validators/MinecraftServerAddressValidator.php @@ -0,0 +1,24 @@ +message, []]; + } + +} diff --git a/console/controllers/CleanupController.php b/console/controllers/CleanupController.php index e2276e8..fc72371 100644 --- a/console/controllers/CleanupController.php +++ b/console/controllers/CleanupController.php @@ -4,12 +4,15 @@ namespace console\controllers; use common\models\AccountSession; use common\models\EmailActivation; use common\models\MinecraftAccessKey; +use common\models\OauthClient; +use common\tasks\ClearOauthSessions; use Yii; use yii\console\Controller; +use yii\console\ExitCode; class CleanupController extends Controller { - public function actionEmailKeys() { + public function actionEmailKeys(): int { $query = EmailActivation::find(); foreach ($this->getEmailActivationsDurationsMap() as $typeId => $expiration) { $query->orWhere([ @@ -24,10 +27,10 @@ class CleanupController extends Controller { $email->delete(); } - return self::EXIT_CODE_NORMAL; + return ExitCode::OK; } - public function actionMinecraftSessions() { + public function actionMinecraftSessions(): int { $expiredMinecraftSessionsQuery = MinecraftAccessKey::find() ->andWhere(['<', 'updated_at', time() - 1209600]); // 2 weeks @@ -36,7 +39,7 @@ class CleanupController extends Controller { $minecraftSession->delete(); } - return self::EXIT_CODE_NORMAL; + return ExitCode::OK; } /** @@ -47,7 +50,7 @@ class CleanupController extends Controller { * У модели AccountSession нет внешних связей, так что целевые записи * могут быть удалены без использования циклов. */ - public function actionWebSessions() { + public function actionWebSessions(): int { AccountSession::deleteAll([ 'OR', ['<', 'last_refreshed_at', time() - 7776000], // 90 days @@ -58,7 +61,24 @@ class CleanupController extends Controller { ], ]); - return self::EXIT_CODE_NORMAL; + return ExitCode::OK; + } + + public function actionOauthClients(): int { + /** @var OauthClient[] $clients */ + $clients = OauthClient::find() + ->onlyDeleted() + ->all(); + foreach ($clients as $client) { + if ($client->getSessions()->exists()) { + Yii::$app->queue->push(ClearOauthSessions::createFromOauthClient($client)); + continue; + } + + $client->delete(); + } + + return ExitCode::OK; } private function getEmailActivationsDurationsMap(): array { diff --git a/console/controllers/RbacController.php b/console/controllers/RbacController.php index 7dbc07b..0c615da 100644 --- a/console/controllers/RbacController.php +++ b/console/controllers/RbacController.php @@ -4,6 +4,7 @@ namespace console\controllers; use common\rbac\Permissions as P; use common\rbac\Roles as R; use common\rbac\rules\AccountOwner; +use common\rbac\rules\OauthClientOwner; use InvalidArgumentException; use Yii; use yii\base\ErrorException; @@ -30,6 +31,9 @@ class RbacController extends Controller { $permChangeAccountEmail = $this->createPermission(P::CHANGE_ACCOUNT_EMAIL); $permManageTwoFactorAuth = $this->createPermission(P::MANAGE_TWO_FACTOR_AUTH); $permBlockAccount = $this->createPermission(P::BLOCK_ACCOUNT); + $permCreateOauthClients = $this->createPermission(P::CREATE_OAUTH_CLIENTS); + $permViewOauthClients = $this->createPermission(P::VIEW_OAUTH_CLIENTS); + $permManageOauthClients = $this->createPermission(P::MANAGE_OAUTH_CLIENTS); $permCompleteOauthFlow = $this->createPermission(P::COMPLETE_OAUTH_FLOW, AccountOwner::class); $permObtainAccountEmail = $this->createPermission(P::OBTAIN_ACCOUNT_EMAIL); @@ -44,6 +48,8 @@ class RbacController extends Controller { $permChangeOwnAccountEmail = $this->createPermission(P::CHANGE_OWN_ACCOUNT_EMAIL, AccountOwner::class); $permManageOwnTwoFactorAuth = $this->createPermission(P::MANAGE_OWN_TWO_FACTOR_AUTH, AccountOwner::class); $permMinecraftServerSession = $this->createPermission(P::MINECRAFT_SERVER_SESSION); + $permViewOwnOauthClients = $this->createPermission(P::VIEW_OWN_OAUTH_CLIENTS, OauthClientOwner::class); + $permManageOwnOauthClients = $this->createPermission(P::MANAGE_OWN_OAUTH_CLIENTS, OauthClientOwner::class); $permEscapeIdentityVerification = $this->createPermission(P::ESCAPE_IDENTITY_VERIFICATION); @@ -56,6 +62,8 @@ class RbacController extends Controller { $authManager->addChild($permChangeOwnAccountPassword, $permChangeAccountPassword); $authManager->addChild($permChangeOwnAccountEmail, $permChangeAccountEmail); $authManager->addChild($permManageOwnTwoFactorAuth, $permManageTwoFactorAuth); + $authManager->addChild($permViewOwnOauthClients, $permViewOauthClients); + $authManager->addChild($permManageOwnOauthClients, $permManageOauthClients); $authManager->addChild($permObtainExtendedAccountInfo, $permObtainAccountInfo); $authManager->addChild($permObtainExtendedAccountInfo, $permObtainAccountEmail); @@ -68,6 +76,9 @@ class RbacController extends Controller { $authManager->addChild($roleAccountsWebUser, $permChangeOwnAccountEmail); $authManager->addChild($roleAccountsWebUser, $permManageOwnTwoFactorAuth); $authManager->addChild($roleAccountsWebUser, $permCompleteOauthFlow); + $authManager->addChild($roleAccountsWebUser, $permCreateOauthClients); + $authManager->addChild($roleAccountsWebUser, $permViewOwnOauthClients); + $authManager->addChild($roleAccountsWebUser, $permManageOwnOauthClients); } private function createRole(string $name): Role { diff --git a/console/migrations/m180224_132027_extend_oauth_clients_attributes.php b/console/migrations/m180224_132027_extend_oauth_clients_attributes.php new file mode 100644 index 0000000..65b774c --- /dev/null +++ b/console/migrations/m180224_132027_extend_oauth_clients_attributes.php @@ -0,0 +1,29 @@ +addColumn('{{%oauth_clients}}', 'type', $this->string()->notNull()->after('secret')); + $this->addColumn('{{%oauth_clients}}', 'website_url', $this->string()->null()->after('redirect_uri')); + $this->addColumn('{{%oauth_clients}}', 'minecraft_server_ip', $this->string()->null()->after('website_url')); + $this->addColumn('{{%oauth_clients}}', 'is_deleted', $this->boolean()->notNull()->defaultValue(false)->after('is_trusted')); + $this->update('{{%oauth_clients}}', [ + 'type' => 'application', + ]); + $this->addColumn('{{%oauth_sessions}}', 'created_at', $this->integer()->unsigned()->notNull()); + $this->update('{{%oauth_sessions}}', [ + 'created_at' => time(), + ]); + } + + public function safeDown() { + $this->dropColumn('{{%oauth_clients}}', 'type'); + $this->dropColumn('{{%oauth_clients}}', 'website_url'); + $this->dropColumn('{{%oauth_clients}}', 'minecraft_server_ip'); + $this->dropColumn('{{%oauth_clients}}', 'is_deleted'); + $this->dropColumn('{{%oauth_sessions}}', 'created_at'); + } + +} diff --git a/docker/cron/cleanup b/docker/cron/cleanup index 9c7e9c0..64077aa 100644 --- a/docker/cron/cleanup +++ b/docker/cron/cleanup @@ -2,3 +2,4 @@ 0 0 * * * root /usr/local/bin/php /var/www/html/yii cleanup/email-keys >/dev/null 2>&1 0 1 * * * root /usr/local/bin/php /var/www/html/yii cleanup/minecraft-sessions >/dev/null 2>&1 0 2 * * * root /usr/local/bin/php /var/www/html/yii cleanup/web-sessions >/dev/null 2>&1 +0 3 * * * root /usr/local/bin/php /var/www/html/yii cleanup/oauth-clients >/dev/null 2>&1 diff --git a/tests/codeception/api/_pages/OauthRoute.php b/tests/codeception/api/_pages/OauthRoute.php index c700211..11b5e18 100644 --- a/tests/codeception/api/_pages/OauthRoute.php +++ b/tests/codeception/api/_pages/OauthRoute.php @@ -3,16 +3,40 @@ namespace tests\codeception\api\_pages; class OauthRoute extends BasePage { - public function validate($queryParams) { + public function validate(array $queryParams): void { $this->getActor()->sendGET('/oauth2/v1/validate', $queryParams); } - public function complete($queryParams = [], $postParams = []) { + public function complete(array $queryParams = [], array $postParams = []): void { $this->getActor()->sendPOST('/oauth2/v1/complete?' . http_build_query($queryParams), $postParams); } - public function issueToken($postParams = []) { + public function issueToken(array $postParams = []): void { $this->getActor()->sendPOST('/oauth2/v1/token', $postParams); } + public function createClient(string $type, array $postParams): void { + $this->getActor()->sendPOST('/v1/oauth2/' . $type, $postParams); + } + + public function updateClient(string $clientId, array $params): void { + $this->getActor()->sendPUT('/v1/oauth2/' . $clientId, $params); + } + + public function deleteClient(string $clientId): void { + $this->getActor()->sendDELETE('/v1/oauth2/' . $clientId); + } + + public function resetClient(string $clientId, bool $regenerateSecret = false): void { + $this->getActor()->sendPOST("/v1/oauth2/$clientId/reset" . ($regenerateSecret ? '?regenerateSecret' : '')); + } + + public function getClient(string $clientId): void { + $this->getActor()->sendGET("/v1/oauth2/$clientId"); + } + + public function getPerAccount(int $accountId): void { + $this->getActor()->sendGET("/v1/accounts/$accountId/oauth2/clients"); + } + } diff --git a/tests/codeception/api/codeception.yml b/tests/codeception/api/codeception.yml index 92cfc34..406850d 100644 --- a/tests/codeception/api/codeception.yml +++ b/tests/codeception/api/codeception.yml @@ -22,7 +22,6 @@ coverage: - ../../../api/* exclude: - ../../../api/config/* - - ../../../api/mails/* - ../../../api/web/* - ../../../api/runtime/* c3url: 'http://localhost/api/web/index.php' diff --git a/tests/codeception/api/functional/oauth/CreateClientCest.php b/tests/codeception/api/functional/oauth/CreateClientCest.php new file mode 100644 index 0000000..4f6cc99 --- /dev/null +++ b/tests/codeception/api/functional/oauth/CreateClientCest.php @@ -0,0 +1,92 @@ +route = new OauthRoute($I); + } + + public function testCreateApplicationWithWrongParams(FunctionalTester $I) { + $I->amAuthenticated('admin'); + + $this->route->createClient('application', []); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'name' => 'error.name_required', + 'redirectUri' => 'error.redirectUri_required', + ], + ]); + + $this->route->createClient('application', [ + 'name' => 'my test oauth client', + 'redirectUri' => 'localhost', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'redirectUri' => 'error.redirectUri_invalid', + ], + ]); + } + + public function testCreateApplication(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $this->route->createClient('application', [ + 'name' => 'My admin application', + 'description' => 'Application description.', + 'redirectUri' => 'http://some-site.com/oauth/ely', + 'websiteUrl' => 'http://some-site.com', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'my-admin-application', + 'name' => 'My admin application', + 'description' => 'Application description.', + 'websiteUrl' => 'http://some-site.com', + 'countUsers' => 0, + 'redirectUri' => 'http://some-site.com/oauth/ely', + ], + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.clientSecret'); + $I->canSeeResponseJsonMatchesJsonPath('$.data.createdAt'); + } + + public function testCreateMinecraftServer(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $this->route->createClient('minecraft-server', [ + 'name' => 'My amazing server', + 'websiteUrl' => 'http://some-site.com', + 'minecraftServerIp' => 'hypixel.com:25565', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'my-amazing-server', + 'name' => 'My amazing server', + 'websiteUrl' => 'http://some-site.com', + 'countUsers' => 0, + 'minecraftServerIp' => 'hypixel.com:25565', + ], + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.clientSecret'); + $I->canSeeResponseJsonMatchesJsonPath('$.data.createdAt'); + } + +} diff --git a/tests/codeception/api/functional/oauth/DeleteClientCest.php b/tests/codeception/api/functional/oauth/DeleteClientCest.php new file mode 100644 index 0000000..7a9ba67 --- /dev/null +++ b/tests/codeception/api/functional/oauth/DeleteClientCest.php @@ -0,0 +1,28 @@ +route = new OauthRoute($I); + } + + public function testDelete(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->deleteClient('first-test-oauth-client'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + } + +} diff --git a/tests/codeception/api/functional/oauth/GetClientsCest.php b/tests/codeception/api/functional/oauth/GetClientsCest.php new file mode 100644 index 0000000..300d969 --- /dev/null +++ b/tests/codeception/api/functional/oauth/GetClientsCest.php @@ -0,0 +1,89 @@ +route = new OauthRoute($I); + } + + public function testGet(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $this->route->getClient('admin-oauth-client'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'clientId' => 'admin-oauth-client', + 'clientSecret' => 'FKyO71iCIlv4YM2IHlLbhsvYoIJScUzTZt1kEK7DQLXXYISLDvURVXK32Q58sHWS', + 'type' => 'application', + 'name' => 'Admin\'s oauth client', + 'description' => 'Personal oauth client', + 'redirectUri' => 'http://some-site.com/oauth/ely', + 'websiteUrl' => '', + 'createdAt' => 1519254133, + ]); + } + + public function testGetNotOwn(FunctionalTester $I) { + $I->amAuthenticated('admin'); + $this->route->getClient('another-test-oauth-client'); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'status' => 403, + 'message' => 'You are not allowed to perform this action.', + ]); + } + + public function testGetAllPerAccountList(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->getPerAccount(14); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + [ + 'clientId' => 'first-test-oauth-client', + 'clientSecret' => 'Zt1kEK7DQLXXYISLDvURVXK32Q58sHWSFKyO71iCIlv4YM2IHlLbhsvYoIJScUzT', + 'type' => 'application', + 'name' => 'First test oauth client', + 'description' => 'Some description to the first oauth client', + 'redirectUri' => 'http://some-site-1.com/oauth/ely', + 'websiteUrl' => '', + 'countUsers' => 0, + 'createdAt' => 1519487434, + ], + [ + 'clientId' => 'another-test-oauth-client', + 'clientSecret' => 'URVXK32Q58sHWSFKyO71iCIlv4YM2Zt1kEK7DQLXXYISLDvIHlLbhsvYoIJScUzT', + 'type' => 'minecraft-server', + 'name' => 'Another test oauth client', + 'websiteUrl' => '', + 'minecraftServerIp' => '136.243.88.97:25565', + 'countUsers' => 0, + 'createdAt' => 1519487472, + ], + ]); + } + + public function testGetAllPerNotOwnAccount(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->getPerAccount(1); + $I->canSeeResponseCodeIs(403); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'name' => 'Forbidden', + 'status' => 403, + 'message' => 'You are not allowed to perform this action.', + ]); + } + +} diff --git a/tests/codeception/api/functional/oauth/ResetClientCest.php b/tests/codeception/api/functional/oauth/ResetClientCest.php new file mode 100644 index 0000000..a5e7cea --- /dev/null +++ b/tests/codeception/api/functional/oauth/ResetClientCest.php @@ -0,0 +1,60 @@ +route = new OauthRoute($I); + } + + public function testReset(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->resetClient('first-test-oauth-client'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'first-test-oauth-client', + 'clientSecret' => 'Zt1kEK7DQLXXYISLDvURVXK32Q58sHWSFKyO71iCIlv4YM2IHlLbhsvYoIJScUzT', + 'name' => 'First test oauth client', + 'description' => 'Some description to the first oauth client', + 'redirectUri' => 'http://some-site-1.com/oauth/ely', + 'websiteUrl' => '', + 'countUsers' => 0, + 'createdAt' => 1519487434, + ], + ]); + } + + public function testResetWithSecretChanging(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->resetClient('first-test-oauth-client', true); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'first-test-oauth-client', + 'name' => 'First test oauth client', + 'description' => 'Some description to the first oauth client', + 'redirectUri' => 'http://some-site-1.com/oauth/ely', + 'websiteUrl' => '', + 'countUsers' => 0, + 'createdAt' => 1519487434, + ], + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.clientSecret'); + $secret = $I->grabDataFromResponseByJsonPath('$.data.clientSecret')[0]; + $I->assertNotEquals('Zt1kEK7DQLXXYISLDvURVXK32Q58sHWSFKyO71iCIlv4YM2IHlLbhsvYoIJScUzT', $secret); + } + +} diff --git a/tests/codeception/api/functional/oauth/UpdateClientCest.php b/tests/codeception/api/functional/oauth/UpdateClientCest.php new file mode 100644 index 0000000..0dc72f8 --- /dev/null +++ b/tests/codeception/api/functional/oauth/UpdateClientCest.php @@ -0,0 +1,65 @@ +route = new OauthRoute($I); + } + + public function testUpdateApplication(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->updateClient('first-test-oauth-client', [ + 'name' => 'Updated name', + 'description' => 'Updated description.', + 'redirectUri' => 'http://new-site.com/oauth/ely', + 'websiteUrl' => 'http://new-site.com', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'first-test-oauth-client', + 'clientSecret' => 'Zt1kEK7DQLXXYISLDvURVXK32Q58sHWSFKyO71iCIlv4YM2IHlLbhsvYoIJScUzT', + 'name' => 'Updated name', + 'description' => 'Updated description.', + 'redirectUri' => 'http://new-site.com/oauth/ely', + 'websiteUrl' => 'http://new-site.com', + 'createdAt' => 1519487434, + 'countUsers' => 0, + ], + ]); + } + + public function testUpdateMinecraftServer(FunctionalTester $I) { + $I->amAuthenticated('TwoOauthClients'); + $this->route->updateClient('another-test-oauth-client', [ + 'name' => 'Updated server name', + 'websiteUrl' => 'http://new-site.com', + 'minecraftServerIp' => 'hypixel.com:25565', + ]); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => true, + 'data' => [ + 'clientId' => 'another-test-oauth-client', + 'clientSecret' => 'URVXK32Q58sHWSFKyO71iCIlv4YM2Zt1kEK7DQLXXYISLDvIHlLbhsvYoIJScUzT', + 'name' => 'Updated server name', + 'websiteUrl' => 'http://new-site.com', + 'minecraftServerIp' => 'hypixel.com:25565', + 'createdAt' => 1519487472, + ], + ]); + } + +} diff --git a/tests/codeception/api/unit/modules/oauth/models/ApplicationTypeTest.php b/tests/codeception/api/unit/modules/oauth/models/ApplicationTypeTest.php new file mode 100644 index 0000000..1eed2c4 --- /dev/null +++ b/tests/codeception/api/unit/modules/oauth/models/ApplicationTypeTest.php @@ -0,0 +1,27 @@ +name = 'Application name'; + $model->websiteUrl = 'http://example.com'; + $model->redirectUri = 'http://example.com/oauth/ely'; + $model->description = 'Application description.'; + + $client = new OauthClient(); + + $model->applyToClient($client); + + $this->assertSame('Application name', $client->name); + $this->assertSame('Application description.', $client->description); + $this->assertSame('http://example.com/oauth/ely', $client->redirect_uri); + $this->assertSame('http://example.com', $client->website_url); + } + +} diff --git a/tests/codeception/api/unit/modules/oauth/models/BaseOauthClientTypeTest.php b/tests/codeception/api/unit/modules/oauth/models/BaseOauthClientTypeTest.php new file mode 100644 index 0000000..b119012 --- /dev/null +++ b/tests/codeception/api/unit/modules/oauth/models/BaseOauthClientTypeTest.php @@ -0,0 +1,24 @@ +makePartial(); + $form->name = 'Application name'; + $form->websiteUrl = 'http://example.com'; + + $form->applyToClient($client); + $this->assertSame('Application name', $client->name); + $this->assertSame('http://example.com', $client->website_url); + } + +} diff --git a/tests/codeception/api/unit/modules/oauth/models/MinecraftServerTypeTest.php b/tests/codeception/api/unit/modules/oauth/models/MinecraftServerTypeTest.php new file mode 100644 index 0000000..8354462 --- /dev/null +++ b/tests/codeception/api/unit/modules/oauth/models/MinecraftServerTypeTest.php @@ -0,0 +1,25 @@ +name = 'Server name'; + $model->websiteUrl = 'http://example.com'; + $model->minecraftServerIp = 'localhost:12345'; + + $client = new OauthClient(); + + $model->applyToClient($client); + + $this->assertSame('Server name', $client->name); + $this->assertSame('http://example.com', $client->website_url); + $this->assertSame('localhost:12345', $client->minecraft_server_ip); + } + +} diff --git a/tests/codeception/api/unit/modules/oauth/models/OauthClientFormFactoryTest.php b/tests/codeception/api/unit/modules/oauth/models/OauthClientFormFactoryTest.php new file mode 100644 index 0000000..642c788 --- /dev/null +++ b/tests/codeception/api/unit/modules/oauth/models/OauthClientFormFactoryTest.php @@ -0,0 +1,49 @@ +type = OauthClient::TYPE_APPLICATION; + $client->name = 'Application name'; + $client->description = 'Application description.'; + $client->website_url = 'http://example.com'; + $client->redirect_uri = 'http://example.com/oauth/ely'; + /** @var ApplicationType $requestForm */ + $requestForm = OauthClientFormFactory::create($client); + $this->assertInstanceOf(ApplicationType::class, $requestForm); + $this->assertSame('Application name', $requestForm->name); + $this->assertSame('Application description.', $requestForm->description); + $this->assertSame('http://example.com', $requestForm->websiteUrl); + $this->assertSame('http://example.com/oauth/ely', $requestForm->redirectUri); + + $client = new OauthClient(); + $client->type = OauthClient::TYPE_MINECRAFT_SERVER; + $client->name = 'Server name'; + $client->website_url = 'http://example.com'; + $client->minecraft_server_ip = 'localhost:12345'; + /** @var MinecraftServerType $requestForm */ + $requestForm = OauthClientFormFactory::create($client); + $this->assertInstanceOf(MinecraftServerType::class, $requestForm); + $this->assertSame('Server name', $requestForm->name); + $this->assertSame('http://example.com', $requestForm->websiteUrl); + $this->assertSame('localhost:12345', $requestForm->minecraftServerIp); + } + + /** + * @expectedException \api\modules\oauth\exceptions\UnsupportedOauthClientType + */ + public function testCreateUnknownType() { + $client = new OauthClient(); + $client->type = 'unknown-type'; + OauthClientFormFactory::create($client); + } + +} diff --git a/tests/codeception/api/unit/modules/oauth/models/OauthClientFormTest.php b/tests/codeception/api/unit/modules/oauth/models/OauthClientFormTest.php new file mode 100644 index 0000000..f9fc446 --- /dev/null +++ b/tests/codeception/api/unit/modules/oauth/models/OauthClientFormTest.php @@ -0,0 +1,140 @@ +shouldReceive('save')->andReturn(true); + $client->account_id = 1; + $client->type = OauthClient::TYPE_APPLICATION; + $client->name = 'Test application'; + + /** @var OauthClientForm|\Mockery\MockInterface $form */ + $form = mock(OauthClientForm::class . '[isClientExists]', [$client]); + $form->shouldAllowMockingProtectedMethods(); + $form->shouldReceive('isClientExists') + ->times(3) + ->andReturnValues([true, true, false]); + + /** @var OauthClientTypeForm|\Mockery\MockInterface $requestType */ + $requestType = mock(OauthClientTypeForm::class); + $requestType->shouldReceive('validate')->once()->andReturn(true); + $requestType->shouldReceive('applyToClient')->once()->withArgs([$client]); + + $this->assertTrue($form->save($requestType)); + $this->assertSame('test-application2', $client->id); + $this->assertNotNull($client->secret); + $this->assertSame(64, mb_strlen($client->secret)); + } + + public function testSaveUpdateExistsModel() { + /** @var OauthClient|\Mockery\MockInterface $client */ + $client = mock(OauthClient::class . '[save]'); + $client->shouldReceive('save')->andReturn(true); + $client->setIsNewRecord(false); + $client->id = 'application-id'; + $client->secret = 'application_secret'; + $client->account_id = 1; + $client->type = OauthClient::TYPE_APPLICATION; + $client->name = 'Application name'; + $client->description = 'Application description'; + $client->redirect_uri = 'http://example.com/oauth/ely'; + $client->website_url = 'http://example.com'; + + /** @var OauthClientForm|\Mockery\MockInterface $form */ + $form = mock(OauthClientForm::class . '[isClientExists]', [$client]); + $form->shouldAllowMockingProtectedMethods(); + $form->shouldReceive('isClientExists')->andReturn(false); + + $request = new class implements OauthClientTypeForm { + + public function load($data): bool { + return true; + } + + public function validate(): bool { + return true; + } + + public function getValidationErrors(): array { + return []; + } + + public function applyToClient(OauthClient $client): void { + $client->name = 'New name'; + $client->description = 'New description.'; + } + + }; + + $this->assertTrue($form->save($request)); + $this->assertSame('application-id', $client->id); + $this->assertSame('application_secret', $client->secret); + $this->assertSame('New name', $client->name); + $this->assertSame('New description.', $client->description); + $this->assertSame('http://example.com/oauth/ely', $client->redirect_uri); + $this->assertSame('http://example.com', $client->website_url); + } + + public function testDelete() { + /** @var OauthClient|\Mockery\MockInterface $client */ + $client = mock(OauthClient::class . '[save]'); + $client->id = 'mocked-id'; + $client->type = OauthClient::TYPE_APPLICATION; + $client->shouldReceive('save')->andReturn(true); + + $form = new OauthClientForm($client); + $this->assertTrue($form->delete()); + $this->assertTrue($form->getClient()->is_deleted); + /** @var ClearOauthSessions $job */ + $job = $this->tester->grabLastQueuedJob(); + $this->assertInstanceOf(ClearOauthSessions::class, $job); + $this->assertSame('mocked-id', $job->clientId); + $this->assertNull($job->notSince); + } + + public function testReset() { + /** @var OauthClient|\Mockery\MockInterface $client */ + $client = mock(OauthClient::class . '[save]'); + $client->id = 'mocked-id'; + $client->secret = 'initial_secret'; + $client->type = OauthClient::TYPE_APPLICATION; + $client->shouldReceive('save')->andReturn(true); + + $form = new OauthClientForm($client); + $this->assertTrue($form->reset()); + $this->assertSame('initial_secret', $form->getClient()->secret); + /** @var ClearOauthSessions $job */ + $job = $this->tester->grabLastQueuedJob(); + $this->assertInstanceOf(ClearOauthSessions::class, $job); + $this->assertSame('mocked-id', $job->clientId); + $this->assertEquals(time(), $job->notSince, '', 2); + } + + public function testResetWithSecret() { + /** @var OauthClient|\Mockery\MockInterface $client */ + $client = mock(OauthClient::class . '[save]'); + $client->id = 'mocked-id'; + $client->secret = 'initial_secret'; + $client->type = OauthClient::TYPE_APPLICATION; + $client->shouldReceive('save')->andReturn(true); + + $form = new OauthClientForm($client); + $this->assertTrue($form->reset(true)); + $this->assertNotSame('initial_secret', $form->getClient()->secret); + /** @var ClearOauthSessions $job */ + $job = $this->tester->grabLastQueuedJob(); + $this->assertInstanceOf(ClearOauthSessions::class, $job); + $this->assertSame('mocked-id', $job->clientId); + $this->assertEquals(time(), $job->notSince, '', 2); + } + +} diff --git a/tests/codeception/common/fixtures/data/accounts.php b/tests/codeception/common/fixtures/data/accounts.php index 382db97..4b8bfed 100644 --- a/tests/codeception/common/fixtures/data/accounts.php +++ b/tests/codeception/common/fixtures/data/accounts.php @@ -186,4 +186,20 @@ return [ 'updated_at' => 1485124685, 'password_changed_at' => 1485124685, ], + 'account-with-two-oauth-clients' => [ + 'id' => 14, + 'uuid' => '1b946267-b1a9-4409-ae83-94f84a329883', + 'username' => 'TwoOauthClients', + 'email' => 'oauth2-two@gmail.com', + 'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 + 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, + 'lang' => 'ru', + 'status' => \common\models\Account::STATUS_ACTIVE, + 'rules_agreement_version' => \common\LATEST_RULES_VERSION, + 'otp_secret' => null, + 'is_otp_enabled' => false, + 'created_at' => 1519487320, + 'updated_at' => 1519487320, + 'password_changed_at' => 1519487320, + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-clients.php b/tests/codeception/common/fixtures/data/oauth-clients.php index e8c2dfc..df4da2c 100644 --- a/tests/codeception/common/fixtures/data/oauth-clients.php +++ b/tests/codeception/common/fixtures/data/oauth-clients.php @@ -3,51 +3,141 @@ return [ 'ely' => [ 'id' => 'ely', 'secret' => 'ZuM1vGchJz-9_UZ5HC3H3Z9Hg5PzdbkM', + 'type' => 'application', 'name' => 'Ely.by', 'description' => 'Всем знакомое елуби', 'redirect_uri' => 'http://ely.by', + 'website_url' => '', + 'minecraft_server_ip' => '', 'account_id' => null, 'is_trusted' => 0, + 'is_deleted' => 0, 'created_at' => 1455309271, ], 'tlauncher' => [ 'id' => 'tlauncher', 'secret' => 'HsX-xXzdGiz3mcsqeEvrKHF47sqiaX94', + 'type' => 'application', 'name' => 'TLauncher', 'description' => 'Лучший альтернативный лаунчер для Minecraft с большим количеством версий и их модификаций, а также возмоностью входа как с лицензионным аккаунтом, так и без него.', 'redirect_uri' => '', + 'website_url' => '', + 'minecraft_server_ip' => '', 'account_id' => null, 'is_trusted' => 0, + 'is_deleted' => 0, 'created_at' => 1455318468, ], 'test1' => [ 'id' => 'test1', 'secret' => 'eEvrKHF47sqiaX94HsX-xXzdGiz3mcsq', + 'type' => 'application', 'name' => 'Test1', 'description' => 'Some description', 'redirect_uri' => 'http://test1.net', + 'website_url' => '', + 'minecraft_server_ip' => '', 'account_id' => null, 'is_trusted' => 0, + 'is_deleted' => 0, 'created_at' => 1479937982, ], 'trustedClient' => [ 'id' => 'trusted-client', 'secret' => 'tXBbyvMcyaOgHMOAXBpN2EC7uFoJAaL9', + 'type' => 'application', 'name' => 'Trusted client', 'description' => 'Это клиент, которому мы доверяем', 'redirect_uri' => null, + 'website_url' => '', + 'minecraft_server_ip' => '', 'account_id' => null, 'is_trusted' => 1, + 'is_deleted' => 0, 'created_at' => 1482922663, ], 'defaultClient' => [ 'id' => 'default-client', 'secret' => 'AzWRy7ZjS1yRQUk2vRBDic8fprOKDB1W', + 'type' => 'application', 'name' => 'Default client', 'description' => 'Это обычный клиент, каких может быть много', 'redirect_uri' => null, + 'website_url' => '', + 'minecraft_server_ip' => '', 'account_id' => null, 'is_trusted' => 0, + 'is_deleted' => 0, 'created_at' => 1482922711, ], + 'admin_oauth_client' => [ + 'id' => 'admin-oauth-client', + 'secret' => 'FKyO71iCIlv4YM2IHlLbhsvYoIJScUzTZt1kEK7DQLXXYISLDvURVXK32Q58sHWS', + 'type' => 'application', + 'name' => 'Admin\'s oauth client', + 'description' => 'Personal oauth client', + 'redirect_uri' => 'http://some-site.com/oauth/ely', + 'website_url' => '', + 'minecraft_server_ip' => '', + 'account_id' => 1, + 'is_trusted' => 0, + 'is_deleted' => 0, + 'created_at' => 1519254133, + ], + 'first_test_oauth_client' => [ + 'id' => 'first-test-oauth-client', + 'secret' => 'Zt1kEK7DQLXXYISLDvURVXK32Q58sHWSFKyO71iCIlv4YM2IHlLbhsvYoIJScUzT', + 'type' => 'application', + 'name' => 'First test oauth client', + 'description' => 'Some description to the first oauth client', + 'redirect_uri' => 'http://some-site-1.com/oauth/ely', + 'website_url' => '', + 'minecraft_server_ip' => '', + 'account_id' => 14, + 'is_trusted' => 0, + 'is_deleted' => 0, + 'created_at' => 1519487434, + ], + 'another_test_oauth_client' => [ + 'id' => 'another-test-oauth-client', + 'secret' => 'URVXK32Q58sHWSFKyO71iCIlv4YM2Zt1kEK7DQLXXYISLDvIHlLbhsvYoIJScUzT', + 'type' => 'minecraft-server', + 'name' => 'Another test oauth client', + 'description' => null, + 'redirect_uri' => null, + 'website_url' => '', + 'minecraft_server_ip' => '136.243.88.97:25565', + 'account_id' => 14, + 'is_trusted' => 0, + 'is_deleted' => 0, + 'created_at' => 1519487472, + ], + 'deleted_oauth_client' => [ + 'id' => 'deleted-oauth-client', + 'secret' => 'YISLDvIHlLbhsvYoIJScUzTURVXK32Q58sHWSFKyO71iCIlv4YM2Zt1kEK7DQLXX', + 'type' => 'application', + 'name' => 'I was deleted :(', + 'description' => null, + 'redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'website_url' => '', + 'minecraft_server_ip' => null, + 'account_id' => 1, + 'is_trusted' => 0, + 'is_deleted' => 1, + 'created_at' => 1519504563, + ], + 'deleted_oauth_client_with_sessions' => [ + 'id' => 'deleted-oauth-client-with-sessions', + 'secret' => 'EK7DQLXXYISLDvIHlLbhsvYoIJScUzTURVXK32Q58sHWSFKyO71iCIlv4YM2Zt1k', + 'type' => 'application', + 'name' => 'I still have some sessions ^_^', + 'description' => null, + 'redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'website_url' => '', + 'minecraft_server_ip' => null, + 'account_id' => 1, + 'is_trusted' => 0, + 'is_deleted' => 1, + 'created_at' => 1519507190, + ], ]; diff --git a/tests/codeception/common/fixtures/data/oauth-sessions.php b/tests/codeception/common/fixtures/data/oauth-sessions.php index 69e0536..678833b 100644 --- a/tests/codeception/common/fixtures/data/oauth-sessions.php +++ b/tests/codeception/common/fixtures/data/oauth-sessions.php @@ -6,6 +6,7 @@ return [ 'owner_id' => 1, 'client_id' => 'test1', 'client_redirect_uri' => 'http://test1.net/oauth', + 'created_at' => 1479944472, ], 'banned-account-session' => [ 'id' => 2, @@ -13,5 +14,22 @@ return [ 'owner_id' => 10, 'client_id' => 'test1', 'client_redirect_uri' => 'http://test1.net/oauth', + 'created_at' => 1481421663, + ], + 'deleted-client-session' => [ + 'id' => 3, + 'owner_type' => 'user', + 'owner_id' => 1, + 'client_id' => 'deleted-oauth-client-with-sessions', + 'client_redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'created_at' => 1519510065, + ], + 'actual-deleted-client-session' => [ + 'id' => 4, + 'owner_type' => 'user', + 'owner_id' => 2, + 'client_id' => 'deleted-oauth-client-with-sessions', + 'client_redirect_uri' => 'http://not-exists-site.com/oauth/ely', + 'created_at' => 1519511568, ], ]; diff --git a/tests/codeception/common/unit/models/OauthClientQueryTest.php b/tests/codeception/common/unit/models/OauthClientQueryTest.php new file mode 100644 index 0000000..e4680e6 --- /dev/null +++ b/tests/codeception/common/unit/models/OauthClientQueryTest.php @@ -0,0 +1,42 @@ + OauthClientFixture::class, + ]; + } + + public function testDefaultHideDeletedEntries() { + /** @var OauthClient[] $clients */ + $clients = OauthClient::find()->all(); + $this->assertEmpty(array_filter($clients, function(OauthClient $client) { + return (bool)$client->is_deleted === true; + })); + $this->assertNull(OauthClient::findOne('deleted-oauth-client')); + } + + public function testAllowFindDeletedEntries() { + /** @var OauthClient[] $clients */ + $clients = OauthClient::find()->includeDeleted()->all(); + $this->assertNotEmpty(array_filter($clients, function(OauthClient $client) { + return (bool)$client->is_deleted === true; + })); + $client = OauthClient::find() + ->includeDeleted() + ->andWhere(['id' => 'deleted-oauth-client']) + ->one(); + $this->assertInstanceOf(OauthClient::class, $client); + $deletedClients = OauthClient::find()->onlyDeleted()->all(); + $this->assertEmpty(array_filter($deletedClients, function(OauthClient $client) { + return (bool)$client->is_deleted === false; + })); + } + +} diff --git a/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php b/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php index 0113ea6..ed387f3 100644 --- a/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php +++ b/tests/codeception/common/unit/rbac/rules/AccountOwnerTest.php @@ -40,6 +40,7 @@ class AccountOwnerTest extends TestCase { Yii::$app->set('user', $component); + $this->assertFalse($rule->execute('token', $item, [])); $this->assertFalse($rule->execute('token', $item, ['accountId' => 2])); $this->assertFalse($rule->execute('token', $item, ['accountId' => '2'])); $this->assertTrue($rule->execute('token', $item, ['accountId' => 1])); @@ -53,11 +54,4 @@ class AccountOwnerTest extends TestCase { $this->assertFalse($rule->execute('token', $item, ['accountId' => 1, 'optionalRules' => true])); } - /** - * @expectedException \yii\base\InvalidParamException - */ - public function testExecuteWithException() { - (new AccountOwner())->execute('', new Item(), []); - } - } diff --git a/tests/codeception/common/unit/rbac/rules/OauthClientOwnerTest.php b/tests/codeception/common/unit/rbac/rules/OauthClientOwnerTest.php new file mode 100644 index 0000000..1919377 --- /dev/null +++ b/tests/codeception/common/unit/rbac/rules/OauthClientOwnerTest.php @@ -0,0 +1,53 @@ + OauthClientFixture::class, + ]; + } + + public function testExecute() { + $rule = new OauthClientOwner(); + $item = new Item(); + + $account = new Account(); + $account->id = 1; + $account->status = Account::STATUS_ACTIVE; + $account->rules_agreement_version = LATEST_RULES_VERSION; + + /** @var IdentityInterface|\Mockery\MockInterface $identity */ + $identity = mock(IdentityInterface::class); + $identity->shouldReceive('getAccount')->andReturn($account); + + /** @var Component|\Mockery\MockInterface $component */ + $component = mock(Component::class . '[findIdentityByAccessToken]', [['secret' => 'secret']]); + $component->shouldDeferMissing(); + $component->shouldReceive('findIdentityByAccessToken')->withArgs(['token'])->andReturn($identity); + + Yii::$app->set('user', $component); + + $this->assertFalse($rule->execute('token', $item, [])); + $this->assertTrue($rule->execute('token', $item, ['clientId' => 'admin-oauth-client'])); + $this->assertFalse($rule->execute('token', $item, ['clientId' => 'not-exists-client'])); + $account->id = 2; + $this->assertFalse($rule->execute('token', $item, ['clientId' => 'admin-oauth-client'])); + $item->name = P::VIEW_OWN_OAUTH_CLIENTS; + $this->assertTrue($rule->execute('token', $item, ['accountId' => 2])); + $this->assertFalse($rule->execute('token', $item, ['accountId' => 1])); + } + +} diff --git a/tests/codeception/common/unit/tasks/ClearOauthSessionsTest.php b/tests/codeception/common/unit/tasks/ClearOauthSessionsTest.php new file mode 100644 index 0000000..a054990 --- /dev/null +++ b/tests/codeception/common/unit/tasks/ClearOauthSessionsTest.php @@ -0,0 +1,55 @@ + fixtures\OauthClientFixture::class, + 'oauthSessions' => fixtures\OauthSessionFixture::class, + ]; + } + + public function testCreateFromClient() { + $client = new OauthClient(); + $client->id = 'mocked-id'; + + $result = ClearOauthSessions::createFromOauthClient($client); + $this->assertInstanceOf(ClearOauthSessions::class, $result); + $this->assertSame('mocked-id', $result->clientId); + $this->assertNull($result->notSince); + + $result = ClearOauthSessions::createFromOauthClient($client, time()); + $this->assertInstanceOf(ClearOauthSessions::class, $result); + $this->assertSame('mocked-id', $result->clientId); + $this->assertEquals(time(), $result->notSince, '', 1); + } + + public function testExecute() { + $task = new ClearOauthSessions(); + $task->clientId = 'deleted-oauth-client-with-sessions'; + $task->notSince = 1519510065; + $task->execute(mock(Queue::class)); + + $this->assertFalse(OauthSession::find()->andWhere(['id' => 3])->exists()); + $this->assertTrue(OauthSession::find()->andWhere(['id' => 4])->exists()); + + $task = new ClearOauthSessions(); + $task->clientId = 'deleted-oauth-client-with-sessions'; + $task->execute(mock(Queue::class)); + + $this->assertFalse(OauthSession::find()->andWhere(['id' => 4])->exists()); + + $task = new ClearOauthSessions(); + $task->clientId = 'some-not-exists-client-id'; + $task->execute(mock(Queue::class)); + } + +} diff --git a/tests/codeception/common/unit/validators/MinecraftServerAddressValidatorTest.php b/tests/codeception/common/unit/validators/MinecraftServerAddressValidatorTest.php new file mode 100644 index 0000000..3483554 --- /dev/null +++ b/tests/codeception/common/unit/validators/MinecraftServerAddressValidatorTest.php @@ -0,0 +1,33 @@ +validate($address, $errors); + $this->assertEquals($shouldBeValid, $errors === null); + } + + public function domainNames() { + return [ + ['localhost', true ], + ['localhost:25565', true ], + ['mc.hypixel.net', true ], + ['mc.hypixel.net:25565', true ], + ['136.243.88.97', true ], + ['136.243.88.97:25565', true ], + ['http://ely.by', false], + ['http://ely.by:80', false], + ['ely.by/abcd', false], + ['ely.by?abcd', false], + ]; + } + +} diff --git a/tests/codeception/console/unit.suite.yml b/tests/codeception/console/unit.suite.yml index baa5a80..a675ae0 100644 --- a/tests/codeception/console/unit.suite.yml +++ b/tests/codeception/console/unit.suite.yml @@ -4,6 +4,7 @@ modules: - Yii2: part: [orm, email, fixtures] - tests\codeception\common\_support\Mockery + - tests\codeception\common\_support\queue\CodeceptionQueueHelper config: Yii2: configFile: '../config/console/unit.php' diff --git a/tests/codeception/console/unit/controllers/CleanupControllerTest.php b/tests/codeception/console/unit/controllers/CleanupControllerTest.php index b957c05..f87d40f 100644 --- a/tests/codeception/console/unit/controllers/CleanupControllerTest.php +++ b/tests/codeception/console/unit/controllers/CleanupControllerTest.php @@ -4,10 +4,10 @@ namespace codeception\console\unit\controllers; use common\models\AccountSession; use common\models\EmailActivation; use common\models\MinecraftAccessKey; +use common\models\OauthClient; +use common\tasks\ClearOauthSessions; use console\controllers\CleanupController; -use tests\codeception\common\fixtures\AccountSessionFixture; -use tests\codeception\common\fixtures\EmailActivationFixture; -use tests\codeception\common\fixtures\MinecraftAccessKeyFixture; +use tests\codeception\common\fixtures; use tests\codeception\console\unit\TestCase; use Yii; @@ -15,9 +15,11 @@ class CleanupControllerTest extends TestCase { public function _fixtures() { return [ - 'emailActivations' => EmailActivationFixture::class, - 'minecraftSessions' => MinecraftAccessKeyFixture::class, - 'accountsSessions' => AccountSessionFixture::class, + 'emailActivations' => fixtures\EmailActivationFixture::class, + 'minecraftSessions' => fixtures\MinecraftAccessKeyFixture::class, + 'accountsSessions' => fixtures\AccountSessionFixture::class, + 'oauthClients' => fixtures\OauthClientFixture::class, + 'oauthSessions' => fixtures\OauthSessionFixture::class, ]; } @@ -56,4 +58,22 @@ class CleanupControllerTest extends TestCase { $this->assertEquals($totalSessionsCount - 2, AccountSession::find()->count()); } + public function testActionOauthClients() { + /** @var OauthClient $deletedClient */ + $totalClientsCount = OauthClient::find()->includeDeleted()->count(); + + $controller = new CleanupController('cleanup', Yii::$app); + $this->assertEquals(0, $controller->actionOauthClients()); + + $this->assertNull(OauthClient::find()->includeDeleted()->andWhere(['id' => 'deleted-oauth-client'])->one()); + $this->assertNotNull(OauthClient::find()->includeDeleted()->andWhere(['id' => 'deleted-oauth-client-with-sessions'])->one()); + $this->assertEquals($totalClientsCount - 1, OauthClient::find()->includeDeleted()->count()); + + /** @var ClearOauthSessions $job */ + $job = $this->tester->grabLastQueuedJob(); + $this->assertInstanceOf(ClearOauthSessions::class, $job); + $this->assertSame('deleted-oauth-client-with-sessions', $job->clientId); + $this->assertNull($job->notSince); + } + }