diff --git a/appinfo/database.xml b/appinfo/database.xml index 66ecdcbdf..07febf9d2 100755 --- a/appinfo/database.xml +++ b/appinfo/database.xml @@ -4,6 +4,7 @@ true false utf8 + *dbprefix*notifications @@ -118,4 +119,64 @@
+ + + *dbprefix*notifications_pushtokens + + + uid + text + true + 64 + + + token + integer + 0 + true + 4 + + + deviceidentifier + text + true + 128 + + + devicepublickey + text + true + 512 + + + devicepublickeyhash + text + true + 128 + + + pushtokenhash + text + true + 128 + + + proxyserver + text + true + 256 + + + + oc_notifpushtoken + true + + uid + + + token + + + +
diff --git a/appinfo/info.xml b/appinfo/info.xml index 47223b535..3873d1813 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -15,7 +15,7 @@ AGPL Joas Schilling - 1.2.0 + 2.0.0 diff --git a/appinfo/routes.php b/appinfo/routes.php index 6c11dc32f..205c35391 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -24,5 +24,7 @@ ['name' => 'Endpoint#listNotifications', 'url' => '/api/{apiVersion}/notifications', 'verb' => 'GET', 'requirements' => ['apiVersion' => 'v(1|2)']], ['name' => 'Endpoint#getNotification', 'url' => '/api/{apiVersion}/notifications/{id}', 'verb' => 'GET', 'requirements' => ['apiVersion' => 'v(1|2)', 'id' => '\d+']], ['name' => 'Endpoint#deleteNotification', 'url' => '/api/{apiVersion}/notifications/{id}', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v(1|2)', 'id' => '\d+']], + ['name' => 'Push#registerDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'POST', 'requirements' => ['apiVersion' => 'v2']], + ['name' => 'Push#removeDevice', 'url' => '/api/{apiVersion}/push', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => 'v2']], ], ]; diff --git a/docs/push-v2.md b/docs/push-v2.md new file mode 100644 index 000000000..84aed3e4e --- /dev/null +++ b/docs/push-v2.md @@ -0,0 +1,213 @@ +# Push notifications as a Nextcloud client device + + + +## Checking the capabilities of the Nextcloud server + +In order to find out if notifications support push on the server you can run a request against the capabilities endpoint: `/ocs/v2.php/cloud/capabilities` + +``` +{ + "ocs": { + ... + "data": { + ... + "capabilities": { + ... + "notifications": { + "push": [ + ... + "devices" + ] + } + } + } + } +} +``` + + + +## Subscribing at the Nextcloud server + +1. **Only on first registration on the server** The device generates a `rsa2048` key pair (`devicePrivateKey` and `devicePublicKey`). + +2. The device generates the `PushToken` for *Apple Push Notification Service* (iOS) or *Firebase Cloud Messaging* (Android) + +3. The device generates a `sha512` hash of the `PushToken` (`PushTokenHash`) + +4. The device then sends the `devicePublicKey`, `PushTokenHash` and `proxyServerUrl` to the Nextcloud server: + + ``` + POST /ocs/v2.php/apps/notifications/api/v2/push + + { + "pushTokenHash": "{{PushTokenHash}}", + "devicePublicKey": "{{devicePublicKey}}", + "proxyServer": "{{proxyServerUrl}}" + } + ``` + + ​ + +### Response + +The server replies with the following status codes: + +| Status code | Meaning | +| ----------- | ---------------------------------------- | +| 200 | No further action by the device required | +| 201 | Push token was created/updated and **needs to be sent to the `Proxy`** | +| 400 | Invalid device public key; device does not use a token to authenticate; the push token hash is invalid formatted; the proxy server URL is invalid; | +| 401 | Device is not logged in | + + + +#### Body in case of success + +In case of `200` and `201` the reply has more information in the body: + +| Key | Type | | +| ---------------- | ------------ | ---------------------------------------- | +| publicKey | string (512) | rsa2048 public key of the user account on the instance | +| deviceIdentifier | string (128) | unique identifier encrypted with the users private key | +| signature | string (512) | base64 encoded signature of the deviceIdentifier | + + + +#### Body in case of an error + +In case of `400` the following `message` can appear in the body: + +| Error | Description | +| ------------------------ | ---------------------------------------- | +| `INVALID_PUSHTOKEN_HASH` | The hash of the push token was not a valid `sha512` hash. | +| `INVALID_SESSION_TOKEN` | The authentication token of the request could not be identified. Check whether a password was used to login. | +| `INVALID_DEVICE_KEY` | The device key does not match the one registered to the provided session token. | +| `INVALID_PROXY_SERVER` | The proxy server was not a valid https URL. | + + + +## Unsubcribing at the Nextcloud server + +When an account is removed from a device, the device should unregister on the server. Otherwise the server sends unnecessary push notifications and might be blocked because of spam. + + + +The device should then send a `DELETE` request to the Nextcloud server: + +``` +DELETE /ocs/v2.php/apps/notifications/api/v2/push +``` + + + +### Response + +The server replies with the following status codes: + +| Status code | Meaning | +| ----------- | ---------------------------------------- | +| 200 | Push token was not registered on the server | +| 202 | Push token was deleted and **needs to be deleted from the `Proxy`** | +| 400 | Device does not use a token to authenticate | +| 401 | Device is not logged in | + + + +#### Body in case of an error + +In case of `400` the following `message` can appear in the body: + +| Error | Description | +| ----------------------- | ---------------------------------------- | +| `INVALID_SESSION_TOKEN` | The authentication token of the request could not be identified. | + + + +## Subscribing at the Push Proxy + +The device sends the`PushToken` as well as the `deviceIdentifier`, `signature` and the user´s `publicKey` (from the server´s response) to the Push Proxy: + +``` +POST /devices + +{ + "pushToken": "{{PushToken}}", + "deviceIdentifier": "{{deviceIdentifier}}", + "deviceIdentifierSignature": "{{signature}}", + "userPublicKey": "{{userPublicKey}}" +} +``` + + + +### Response + +The server replies with the following status codes: + +| Status code | Meaning | +| ----------- | ---------------------------------------- | +| 200 | Push token was written to the databse | +| 400 | Push token, public key or device identifier is malformed, the signature does not match | +| 403 | Device is not allowed to write the push token of the device identifier | +| 409 | In case of a conflict the device can retry with the additional field `cloudId` with the value `{{userid}}@{{serverurl}}` which allows the proxy to verify the public key and device identifier belongs to the given user on the instance | + + + +## Unsubscribing at the Push Proxy + +The device sends the `deviceIdentifier`, `deviceIdentifierSignature` and the user´s `publicKey` (from the server´s response) to the Push Proxy: + +``` +DELETE /devices + +{ + "deviceIdentifier": "{{deviceIdentifier}}", + "deviceIdentifierSignature": "{{signature}}", + "userPublicKey": "{{userPublicKey}}" +} +``` + + + +### Response + +The server replies with the following status codes: + +| Status code | Meaning | +| ----------- | ---------------------------------------- | +| 200 | Push token was deleted from the database | +| 400 | Public key or device identifier is malformed | +| 403 | Device identifier and device public key didn't match or could not be found | + + + +## Pushed notifications + +The pushed notifications is defined by the [Firebase Cloud Messaging HTTP Protocol](https://firebase.google.com/docs/cloud-messaging/http-server-ref#send-downstream). The sample content of a Nextcloud push notification looks like the following: + +```json +{ + "to" : "APA91bHun4MxP5egoKMwt2KZFBaFUH-1RYqx...", + "notification" : { + "body" : "NEW_NOTIFICATION", + "body_loc_key" : "NEW_NOTIFICATION", + "title" : "NEW_NOTIFICATION", + "title_loc_key" : "NEW_NOTIFICATION" + }, + "data" : { + "subject" : "*Encrypted subject*", + "signature" : "*Signature*" + } +} +``` + +| Attribute | Meaning | +| ----------- | ---------------------------------------- | +| `subject` | The subject is encrypted with the device´s *public key*. | +| `signature` | The signature is a sha512 signature over the encrypted subject using the user´s private key. | + +### Verification +So a device should verify the signature using the user´s public key. +If the signature is okay, the subject can be decrypted using the device´s private key. diff --git a/lib/App.php b/lib/App.php index 6cb0a8a59..ccc2e4f0d 100644 --- a/lib/App.php +++ b/lib/App.php @@ -28,9 +28,12 @@ class App implements IApp { /** @var Handler */ protected $handler; + /** @var Push */ + protected $push; - public function __construct(Handler $handler) { + public function __construct(Handler $handler, Push $push) { $this->handler = $handler; + $this->push = $push; } /** @@ -39,7 +42,10 @@ public function __construct(Handler $handler) { * @since 8.2.0 */ public function notify(INotification $notification) { - $this->handler->add($notification); + $notificationId = $this->handler->add($notification); + + $notificationToPush = $this->handler->getById($notificationId, $notification->getUser()); + $this->push->pushToDevice($notificationToPush); } /** diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index c1577a0ac..af7e6259e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,9 +21,11 @@ namespace OCA\Notifications\AppInfo; +use OC\Authentication\Token\IProvider; use OCA\Notifications\App; use OCA\Notifications\Capabilities; use OCA\Notifications\Controller\EndpointController; +use OCP\AppFramework\IAppContainer; use OCP\Util; class Application extends \OCP\AppFramework\App { @@ -31,8 +33,12 @@ public function __construct() { parent::__construct('notifications'); $container = $this->getContainer(); - $container->registerAlias('EndpointController', EndpointController::class); $container->registerCapability(Capabilities::class); + + // FIXME this is for automatic DI because it is not in DIContainer + $container->registerService(IProvider::class, function(IAppContainer $c) { + return $c->getServer()->query(IProvider::class); + }); } public function register() { diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a6ca12caf..6b832f628 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -45,6 +45,9 @@ public function getCapabilities() { 'icons', 'rich-strings', ], + 'push' => [ + 'devices', + ], ], ]; } diff --git a/lib/Controller/PushController.php b/lib/Controller/PushController.php new file mode 100644 index 000000000..aef6eb3d7 --- /dev/null +++ b/lib/Controller/PushController.php @@ -0,0 +1,242 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Notifications\Controller; + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OC\Security\IdentityProof\Manager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserSession; + +class PushController extends OCSController { + + /** @var IDBConnection */ + private $db; + + /** @var ISession */ + private $session; + + /** @var IUserSession */ + private $userSession; + + /** @var IProvider */ + private $tokenProvider; + + /** @var Manager */ + private $identityProof; + + /** + * @param string $appName + * @param IRequest $request + * @param IDBConnection $db + * @param ISession $session + * @param IUserSession $userSession + * @param IProvider $tokenProvider + * @param Manager $identityProof + */ + public function __construct($appName, IRequest $request, IDBConnection $db, ISession $session, IUserSession $userSession, IProvider $tokenProvider, Manager $identityProof) { + parent::__construct($appName, $request); + + $this->db = $db; + $this->session = $session; + $this->userSession = $userSession; + $this->tokenProvider = $tokenProvider; + $this->identityProof = $identityProof; + } + + /** + * @NoAdminRequired + * + * @param string $pushTokenHash + * @param string $devicePublicKey + * @param string $proxyServer + * @return DataResponse + */ + public function registerDevice($pushTokenHash, $devicePublicKey, $proxyServer) { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + if (!preg_match('/^([a-f0-9]{128})$/', $pushTokenHash)) { + return new DataResponse(['message' => 'INVALID_PUSHTOKEN_HASH'], Http::STATUS_BAD_REQUEST); + } + + if ( + ((strlen($devicePublicKey) !== 450 || strpos($devicePublicKey, "\n" . '-----END PUBLIC KEY-----') !== 425) && + (strlen($devicePublicKey) !== 451 || strpos($devicePublicKey, "\n" . '-----END PUBLIC KEY-----' . "\n") !== 425)) || + strpos($devicePublicKey, '-----BEGIN PUBLIC KEY-----' . "\n") !== 0) { + return new DataResponse(['message' => 'INVALID_DEVICE_KEY'], Http::STATUS_BAD_REQUEST); + } + + if ( + !filter_var($proxyServer, FILTER_VALIDATE_URL) || + strlen($proxyServer) > 256 || + !preg_match('/^(https\:\/\/|http\:\/\/localhost(\:[0-9]{0,5})?\/)/', $proxyServer) + ) { + return new DataResponse(['message' => 'INVALID_PROXY_SERVER'], Http::STATUS_BAD_REQUEST); + } + + $tokenId = $this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + $key = $this->identityProof->getKey($user); + + $deviceIdentifier = json_encode([$user->getCloudId(), $token->getId()]); + openssl_sign($deviceIdentifier, $signature, $key->getPrivate(), OPENSSL_ALGO_SHA512); + $deviceIdentifier = base64_encode(hash('sha512', $deviceIdentifier, true)); + + $created = $this->savePushToken($user, $token, $deviceIdentifier, $devicePublicKey, $pushTokenHash, $proxyServer); + + return new DataResponse([ + 'publicKey' => $key->getPublic(), + 'deviceIdentifier' => $deviceIdentifier, + 'signature' => base64_encode($signature), + ], $created ? Http::STATUS_CREATED : Http::STATUS_OK); + } + + /** + * @NoAdminRequired + * + * @return DataResponse + */ + public function removeDevice() { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + $tokenId = $this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + if ($this->deletePushToken($user, $token)) { + return new DataResponse([], Http::STATUS_ACCEPTED); + } + + return new DataResponse([], Http::STATUS_OK); + } + + /** + * @param IUser $user + * @param IToken $token + * @param string $deviceIdentifier + * @param string $devicePublicKey + * @param string $pushTokenHash + * @param string $proxyServer + * @return bool If the hash was new to the database + */ + protected function savePushToken(IUser $user, IToken $token, $deviceIdentifier, $devicePublicKey, $pushTokenHash, $proxyServer) { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('notifications_pushtokens') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))); + $result = $query->execute(); + $row = $result->fetch(); + $result->closeCursor(); + + if (!$row) { + return $this->insertPushToken($user, $token, $deviceIdentifier, $devicePublicKey, $pushTokenHash, $proxyServer); + } + + return $this->updatePushToken($user, $token, $devicePublicKey, $pushTokenHash, $proxyServer); + } + + /** + * @param IUser $user + * @param IToken $token + * @param string $deviceIdentifier + * @param string $devicePublicKey + * @param string $pushTokenHash + * @param string $proxyServer + * @return bool If the entry was created + */ + protected function insertPushToken(IUser $user, IToken $token, $deviceIdentifier, $devicePublicKey, $pushTokenHash, $proxyServer) { + $devicePublicKeyHash = hash('sha512', $devicePublicKey); + + $query = $this->db->getQueryBuilder(); + $query->insert('notifications_pushtokens') + ->values([ + 'uid' => $query->createNamedParameter($user->getUID()), + 'token' => $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), + 'deviceidentifier' => $query->createNamedParameter($deviceIdentifier), + 'devicepublickey' => $query->createNamedParameter($devicePublicKey), + 'devicepublickeyhash' => $query->createNamedParameter($devicePublicKeyHash), + 'pushtokenhash' => $query->createNamedParameter($pushTokenHash), + 'proxyserver' => $query->createNamedParameter($proxyServer), + ]); + return $query->execute() > 0; + } + + /** + * @param IUser $user + * @param IToken $token + * @param string $devicePublicKey + * @param string $pushTokenHash + * @param string $proxyServer + * @return bool If the entry was updated + */ + protected function updatePushToken(IUser $user, IToken $token, $devicePublicKey, $pushTokenHash, $proxyServer) { + $devicePublicKeyHash = hash('sha512', $devicePublicKey); + + $query = $this->db->getQueryBuilder(); + $query->update('notifications_pushtokens') + ->set('devicepublickey', $query->createNamedParameter($devicePublicKey)) + ->set('devicepublickeyhash', $query->createNamedParameter($devicePublicKeyHash)) + ->set('pushtokenhash', $query->createNamedParameter($pushTokenHash)) + ->set('proxyserver', $query->createNamedParameter($proxyServer)) + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + + return $query->execute() !== 0; + } + + /** + * @param IUser $user + * @param IToken $token + * @return bool If the entry was deleted + */ + protected function deletePushToken(IUser $user, IToken $token) { + $query = $this->db->getQueryBuilder(); + $query->delete('notifications_pushtokens') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + + return $query->execute() !== 0; + } +} diff --git a/lib/Push.php b/lib/Push.php new file mode 100644 index 000000000..5859e256b --- /dev/null +++ b/lib/Push.php @@ -0,0 +1,211 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Notifications; + + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IProvider; +use OC\Security\IdentityProof\Key; +use OC\Security\IdentityProof\Manager; +use OCP\AppFramework\Http; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\ILogger; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\Notification\INotification; + +class Push { + /** @var IDBConnection */ + protected $db; + /** @var INotificationManager */ + protected $notificationManager; + /** @var IConfig */ + protected $config; + /** @var IProvider */ + protected $tokenProvider; + /** @var Manager */ + private $keyManager; + /** @var IUserManager */ + private $userManager; + /** @var IClientService */ + protected $clientService; + /** @var ILogger */ + protected $log; + + public function __construct(IDBConnection $connection, INotificationManager $notificationManager, IConfig $config, IProvider $tokenProvider, Manager $keyManager, IUserManager $userManager, IClientService $clientService, ILogger $log) { + $this->db = $connection; + $this->notificationManager = $notificationManager; + $this->config = $config; + $this->tokenProvider = $tokenProvider; + $this->keyManager = $keyManager; + $this->userManager = $userManager; + $this->clientService = $clientService; + $this->log = $log; + } + + /** + * @param INotification $notification + */ + public function pushToDevice(INotification $notification) { + $user = $this->userManager->get($notification->getUser()); + if (!($user instanceof IUser)) { + return; + } + + $devices = $this->getDevicesForUser($notification->getUser()); + if (empty($devices)) { + return; + } + + $language = $this->config->getUserValue($notification->getUser(), 'core', 'lang', 'en'); + try { + $notification = $this->notificationManager->prepare($notification, $language); + } catch (\InvalidArgumentException $e) { + return; + } + + $userKey = $this->keyManager->getKey($user); + + $pushNotifications = []; + foreach ($devices as $device) { + try { + $payload = json_encode($this->encryptAndSign($userKey, $device, $notification)); + + $proxyServer = rtrim($device['proxyserver'], '/'); + if (!isset($pushNotifications[$proxyServer])) { + $pushNotifications[$proxyServer] = []; + } + $pushNotifications[$proxyServer][] = $payload; + } catch (InvalidTokenException $e) { + // Token does not exist anymore, should drop the push device entry + $this->deletePushToken($device['token']); + } catch (\InvalidArgumentException $e) { + // Failed to encrypt message for device: public key is invalid + $this->deletePushToken($device['token']); + } + } + + if (empty($pushNotifications)) { + return; + } + + $client = $this->clientService->newClient(); + foreach ($pushNotifications as $proxyServer => $notifications) { + try { + $response = $client->post($proxyServer . '/notifications', [ + 'body' => [ + 'notifications' => $notifications, + ], + ]); + } catch (\Exception $e) { + $this->log->logException($e, [ + 'app' => 'notifications', + ]); + continue; + } + + $status = $response->getStatusCode(); + if ($status !== Http::STATUS_OK && $status !== Http::STATUS_SERVICE_UNAVAILABLE) { + $body = $response->getBody(); + $this->log->error('Could not send notification to push server [{url}]: {error}',[ + 'error' => is_string($body) ? $body : 'no reason given', + 'url' => $proxyServer, + 'app' => 'notifications', + ]); + } else if ($status === Http::STATUS_SERVICE_UNAVAILABLE && $this->config->getSystemValue('debug', false)) { + $body = $response->getBody(); + $this->log->debug('Could not send notification to push server [{url}]: {error}',[ + 'error' => is_string($body) ? $body : 'no reason given', + 'url' => $proxyServer, + 'app' => 'notifications', + ]); + } + } + } + + /** + * @param Key $userKey + * @param array $device + * @param INotification $notification + * @return array + * @throws InvalidTokenException + * @throws \InvalidArgumentException + */ + protected function encryptAndSign(Key $userKey, array $device, INotification $notification) { + // Check if the token is still valid... + $this->tokenProvider->getTokenById($device['token']); + + $data = [ + 'app' => $notification->getApp(), + 'subject' => $notification->getParsedSubject(), + ]; + + if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) { + $this->log->error(openssl_error_string(), ['app' => 'notifications']); + throw new \InvalidArgumentException('Failed to encrypt message for device'); + } + + openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512); + $base64EncryptedSubject = base64_encode(hash('sha512', $encryptedSubject, true)); + $base64Signature = base64_encode($signature); + + return [ + 'deviceIdentifier' => $device['deviceidentifier'], + 'pushTokenHash' => $device['pushtokenhash'], + 'subject' => $base64EncryptedSubject, + 'signature' => $base64Signature, + ]; + } + + /** + * @param string $uid + * @return array[] + */ + protected function getDevicesForUser($uid) { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('notifications_pushtokens') + ->where($query->expr()->eq('uid', $query->createNamedParameter($uid))); + + $result = $query->execute(); + $devices = $result->fetchAll(); + $result->closeCursor(); + + return $devices; + } + + /** + * @param int $tokenId + * @return bool + */ + protected function deletePushToken($tokenId) { + $query = $this->db->getQueryBuilder(); + $query->delete('notifications_pushtokens') + ->where($query->expr()->eq('token', $query->createNamedParameter($tokenId, IQueryBuilder::PARAM_INT))); + + return $query->execute() !== 0; + } +} diff --git a/tests/Unit/AppInfo/AppTest.php b/tests/Unit/AppInfo/AppTest.php index 0b1ccea61..9e4ec8f99 100644 --- a/tests/Unit/AppInfo/AppTest.php +++ b/tests/Unit/AppInfo/AppTest.php @@ -22,6 +22,7 @@ namespace OCA\Notifications\Tests\Unit\AppInfo; +use OC\User\Session; use OCA\Notifications\App; use OCA\Notifications\Tests\Unit\TestCase; use OCP\IRequest; @@ -46,14 +47,9 @@ class AppTest extends TestCase { protected function setUp() { parent::setUp(); - $this->manager = $this->getMockBuilder(IManager::class) - ->getMock(); - - $this->request = $this->getMockBuilder(IRequest::class) - ->getMock(); - - $this->session = $this->getMockBuilder(IUserSession::class) - ->getMock(); + $this->manager = $this->createMock(IManager::class); + $this->request = $this->createMock(IRequest::class); + $this->session = $this->createMock(Session::class); $this->overwriteService('NotificationManager', $this->manager); $this->overwriteService('Request', $this->request); diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index 3dd659516..652de2607 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -26,7 +26,9 @@ use OCA\Notifications\AppInfo\Application; use OCA\Notifications\Capabilities; use OCA\Notifications\Controller\EndpointController; +use OCA\Notifications\Controller\PushController; use OCA\Notifications\Handler; +use OCA\Notifications\Push; use OCA\Notifications\Tests\Unit\TestCase; use OCP\AppFramework\IAppContainer; use OCP\AppFramework\OCSController; @@ -63,12 +65,13 @@ public function dataContainerQuery() { array(App::class, IApp::class), array(Capabilities::class), array(Handler::class), + array(Push::class), // Controller/ - array('EndpointController', EndpointController::class), - array('EndpointController', OCSController::class), array(EndpointController::class), array(EndpointController::class, OCSController::class), + array(PushController::class), + array(PushController::class, OCSController::class), ); } diff --git a/tests/Unit/AppInfo/RoutesTest.php b/tests/Unit/AppInfo/RoutesTest.php index ccac67307..4e584321a 100644 --- a/tests/Unit/AppInfo/RoutesTest.php +++ b/tests/Unit/AppInfo/RoutesTest.php @@ -37,6 +37,6 @@ public function testRoutes() { $this->assertCount(1, $routes); $this->assertArrayHasKey('ocs', $routes); $this->assertInternalType('array', $routes['ocs']); - $this->assertGreaterThanOrEqual(3, sizeof($routes['ocs'])); + $this->assertCount(5, $routes['ocs']); } } diff --git a/tests/Unit/AppTest.php b/tests/Unit/AppTest.php index 691a562c7..64e395f09 100644 --- a/tests/Unit/AppTest.php +++ b/tests/Unit/AppTest.php @@ -25,12 +25,14 @@ use OCA\Notifications\App; use OCA\Notifications\Handler; +use OCA\Notifications\Push; use OCP\Notification\INotification; class AppTest extends TestCase { /** @var Handler|\PHPUnit_Framework_MockObject_MockObject */ protected $handler; - + /** @var Push|\PHPUnit_Framework_MockObject_MockObject */ + protected $push; /** @var INotification|\PHPUnit_Framework_MockObject_MockObject */ protected $notification; @@ -40,34 +42,68 @@ class AppTest extends TestCase { protected function setUp() { parent::setUp(); - $this->handler = $this->getMockBuilder(Handler::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->notification = $this->getMockBuilder(INotification::class) - ->disableOriginalConstructor() - ->getMock(); + $this->handler = $this->createMock(Handler::class); + $this->push = $this->createMock(Push::class); + $this->notification = $this->createMock(INotification::class); $this->app = new App( - $this->handler + $this->handler, + $this->push ); } - public function testNotify() { + public function dataNotify() { + return [ + [23, 'user1'], + [42, 'user2'], + ]; + } + + /** + * @dataProvider dataNotify + * + * @param int $id + * @param string $user + */ + public function testNotify($id, $user) { + $this->notification->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->handler->expects($this->once()) ->method('add') + ->with($this->notification) + ->willReturn($id); + $this->handler->expects($this->once()) + ->method('getById') + ->with($id, $user) + ->willReturn($this->notification); + $this->push->expects($this->once()) + ->method('pushToDevice') ->with($this->notification); $this->app->notify($this->notification); } - public function testGetCount() { + public function dataGetCount() { + return [ + [23], + [42], + ]; + } + + /** + * @dataProvider dataGetCount + * + * @param int $count + */ + public function testGetCount($count) { $this->handler->expects($this->once()) ->method('count') ->with($this->notification) - ->willReturn(42); + ->willReturn($count); - $this->assertSame(42, $this->app->getCount($this->notification)); + $this->assertSame($count, $this->app->getCount($this->notification)); } public function testMarkProcessed() { diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php index 764e8de8e..802d2b9e1 100644 --- a/tests/Unit/CapabilitiesTest.php +++ b/tests/Unit/CapabilitiesTest.php @@ -38,6 +38,9 @@ public function testGetCapabilities() { 'icons', 'rich-strings', ], + 'push' => [ + 'devices', + ], ], ], $capabilities->getCapabilities()); } diff --git a/tests/Unit/Controller/PushControllerTest.php b/tests/Unit/Controller/PushControllerTest.php new file mode 100644 index 000000000..2d2401dc1 --- /dev/null +++ b/tests/Unit/Controller/PushControllerTest.php @@ -0,0 +1,508 @@ + + * @author Thomas Müller + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Notifications\Tests\Unit\Controller; + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OC\Security\IdentityProof\Key; +use OC\Security\IdentityProof\Manager; +use OCA\Notifications\Controller\PushController; +use OCA\Notifications\Tests\Unit\TestCase; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\IDBConnection; +use OCP\IRequest; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserSession; + +class PushControllerTest extends TestCase { + /** @var IRequest|\PHPUnit_Framework_MockObject_MockObject */ + protected $request; + /** @var IDBConnection|\PHPUnit_Framework_MockObject_MockObject */ + protected $db; + /** @var ISession|\PHPUnit_Framework_MockObject_MockObject */ + protected $session; + /** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */ + protected $userSession; + /** @var IProvider|\PHPUnit_Framework_MockObject_MockObject */ + protected $tokenProvider; + /** @var Manager|\PHPUnit_Framework_MockObject_MockObject */ + protected $identityProof; + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject */ + protected $user; + /** @var PushController */ + protected $controller; + + protected $devicePublicKey = '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Or1KumSDfk8dT0MuCW9 +WS5wkVOpNsbz2OIJFBYrBvu6joC2iQo9StONMaXoTQj5Ucak9UBtC60PHyTkIDFb +HOpCST5onmIAtZdqHN/3ABOBeHVU/notdRIl/menGM64jiqGWvE06F1+yZ8GGcGQ +8RKzabqMd2K1iUohXP625uzTABVaiwz3u8nGEwui5R6Pf5Fy6DccuqdUMtJIfW21 +Z4Tj48Tw+pR+fUrGpa1Wg+wiwlg7ISK8Symml1Rd6hSRXK2t8Opm/kjH9ZX8oVwn +RSO1ehjzRpTY+gdw/5gvwMZI0XmrIanZmZHwePRR4HC6FLPrL2OQG3gWikDIPyTS +hQIDAQAB +-----END PUBLIC KEY-----'; + + protected $userPrivateKey = '-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDPR0uV6e1cNSoy +vsITBvGyYpOIn9vI7zpEhk7FGGwdOTd2dxxJ2ikegRJ6Fr2Ojce15K3zfiasXPen +TAQuFEXecGoP9WY+DS5X1LfCpj9EeAOBfVGKeQDst5z/GoXeU+YqWbayJTp6vFRj +7o5X6QDCCXy25Kt4snNDWTHPlMc44BLjZ6w+Wj0D2ySlz1dGpunc0vwYN/uEyjr9 +ztmiN82TZtZHgzN43DJSv7tLufsZgGsWnVlytXmsi4QuCAKcm92X2ZtIXkn5niMW +DxJJepqFx7pC3ILXMZKYolAtt91VvLiGQjzURhq7HA4QdqvFyKXp0uLN2rKZjqQ0 +2nUzC34XAgMBAAECggEAFrL/Ew7IIKXt1hrP1BeZlmh3MaoX/pw8LE7tB2aSSG0A +pueKYIgUorON23LsFVVvfnrpldXF1HBl6ptHhehQcnirFM5SAQ+eeJ3h9d4Q5aWi +9KZNrLVtpX7CIam86UkU1qR2fnHXQqOnNj5ktjndDGLPlpPaN2CLgN+etdXcL10g +G5fltrFnTzYgkYap/eNkY+ivA+0xqc1l3jP2i5PHihv1adcoiOuam36GARM9C51X +fyWvMtxMvkRAZsdTATtRcQsEoJuQ3Rvseei38forkQdRn9p61UW8VT6Wa/+DWebO +Ll4OAv1RH4H2V6nrYY2ILJNnPzP8V4hjP9OGEAUQ8QKBgQDssSBUmb8Ztt6SsHNr +fgnbJBGAYizB1oAr6W1kLTQCq+BYirSYWMcJ/rakx+VCPmZ1fbbGYjPX5yVUsskx +jQ/GUT7D8lMIQNZiI9CqWR0+fJpVJ/zxwrPT2jqu8lEJxq2i/WB0nRHCgosGBTmw +UqhRGLkE5Ds14Q0zePZbdpAAyQKBgQDgL+yftcJEam8c3ipkrv02aT7vghoB0pAg +JNSSwhXED1CTboccY4daOfTYdt/PnkVmndENrUGMRyEbAY0DDK6hclG6/gE3fwn4 +mL33IIzQ9BCoXxr3tcS0r4iQjbGKorUNJW1OwmkqyMZ4POF9BSkLXpTTcJaM5WxU +8JU9PmLX3wKBgFNpuLMX27j8MUQQ2xwuttp7w48zCgLlzRWsldiP9ZxbZhzOBQcL +glmLYmJ/79OAmisduqP/R7X2x7kpqK3FwKFrUGtNouVttB+x73+ZGC1FTD5mcUXi +D+3BIp002EpRsi+Wi7+M+w1JZCUjAkmZV6f8xndq11MNlNFm96sUBXvBAoGAJ9hc +tgYYARDprrfN0RdI6eLKzMbS2IAUHaJuJadZNv+B0rJSUTlfVSn32oFGRiBbNWHX +RhcFD2mU+LfN2DzozMkEvbdnf/WUUBrVqJagcILwcvx0TpJ/451PKGIGrB0/EJcW +Vmk3R+NnYvdvHElOgjbNPMdF+sTL/EzGOZxc9QECgYBNY4LAAKqrw47p+lcRi31O +X4fhdGWAIFyiUliPDkxzEl8857FbT5c6qhdes3Gyc9tSF1wh0X7lpCDquWXYLP1V +9WNvdon+YMRi9BKpO0SlE07lwFANBpz+wJkhONVJBMzvKbxEnMRPRJ4lWa0VAAGE +j2ZL3j2Nwefj3HrR/AkeFA== +-----END PRIVATE KEY----- +'; + + protected $userPublicKey = '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz0dLlentXDUqMr7CEwbx +smKTiJ/byO86RIZOxRhsHTk3dnccSdopHoESeha9jo3HteSt834mrFz3p0wELhRF +3nBqD/VmPg0uV9S3wqY/RHgDgX1RinkA7Lec/xqF3lPmKlm2siU6erxUY+6OV+kA +wgl8tuSreLJzQ1kxz5THOOAS42esPlo9A9skpc9XRqbp3NL8GDf7hMo6/c7ZojfN +k2bWR4MzeNwyUr+7S7n7GYBrFp1ZcrV5rIuELggCnJvdl9mbSF5J+Z4jFg8SSXqa +hce6QtyC1zGSmKJQLbfdVby4hkI81EYauxwOEHarxcil6dLizdqymY6kNNp1Mwt+ +FwIDAQAB +-----END PUBLIC KEY----- +'; + + + protected function setUp() { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->db = $this->createMock(IDBConnection::class); + $this->session = $this->createMock(ISession::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->identityProof = $this->createMock(Manager::class); + } + + protected function getController(array $methods = []) { + if (empty($methods)) { + return new PushController( + 'notifications', + $this->request, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof + ); + } + + return $this->getMockBuilder(PushController::class) + ->setConstructorArgs([ + 'notifications', + $this->request, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof, + ]) + ->setMethods($methods) + ->getMock(); + } + + public function dataRegisterDevice() { + return [ + 'not authenticated' => [ + '', + '', + '', + false, + 0, + false, + null, + [], + Http::STATUS_UNAUTHORIZED + ], + 'too short token hash' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e47', + '', + '', + true, + 0, + false, + null, + ['message' => 'INVALID_PUSHTOKEN_HASH'], + Http::STATUS_BAD_REQUEST, + ], + 'too long token hash' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e4722', + '', + '', + true, + 0, + false, + null, + ['message' => 'INVALID_PUSHTOKEN_HASH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid char in token hash' => [ + 'rb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + '', + '', + true, + 0, + false, + null, + ['message' => 'INVALID_PUSHTOKEN_HASH'], + Http::STATUS_BAD_REQUEST, + ], + 'device key invalid start' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + substr($this->devicePublicKey, 1), + '', + true, + 0, + false, + null, + ['message' => 'INVALID_DEVICE_KEY'], + Http::STATUS_BAD_REQUEST, + ], + 'device key invalid end' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + substr($this->devicePublicKey, 0, -1), + '', + true, + 0, + false, + null, + ['message' => 'INVALID_DEVICE_KEY'], + Http::STATUS_BAD_REQUEST, + ], + 'device key too much end' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey . "\n\n", + '', + true, + 0, + false, + null, + ['message' => 'INVALID_DEVICE_KEY'], + Http::STATUS_BAD_REQUEST, + ], + 'device key without trailing new line' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + '', + true, + 0, + false, + null, + ['message' => 'INVALID_PROXY_SERVER'], + Http::STATUS_BAD_REQUEST, + ], + 'device key with trailing new line' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey . "\n", + '', + true, + 0, + false, + null, + ['message' => 'INVALID_PROXY_SERVER'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid push proxy' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'localhost', + true, + 0, + false, + null, + ['message' => 'INVALID_PROXY_SERVER'], + Http::STATUS_BAD_REQUEST, + ], + 'using localhost' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'http://localhost/', + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'using localhost with port' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'http://localhost:8088/', + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'using production' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'https://push-notifications.nextcloud.com/', + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'created or updated' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'https://push-notifications.nextcloud.com/', + true, + 23, + true, + true, + [ + 'publicKey' => $this->userPublicKey, + 'deviceIdentifier' => 'XUCEZ1EHvTUcVhIvrQQQ1XcP0ZD2BFdFqw4EYbOhBfiEgXgirurR4x/ve4GSSyfivvbQOdOkZUM+g4m+tSb0Ew==', + 'signature' => 'LRhbXO71WYX9qqDbQX7C+87YaaFfWoT/vG0DlaXdBz6+lhyOA0dw/1Ggz3fd7RerCQ0MfgnnTyxO+cSeRpUaPdA2yPjfoiPpfYA5SOJQGF3comS/HYna3fHiFDbOoM3BJOnjvqiSZdxA/ICdyl2mEEC5wO7AZ4OZKBTa5XfL7eSCXZLEv1YldqcLOStbXrI7voDQocTMJxoQZI/j8BVcf2i3D6F454aXIFDrYYzC2PQY+CKJoXZW0m0RMWaTM2B8tBmFFwrmaGLDqcjjpd33TsTtsV5DB7WimffLBPpOuGV4Z1Kiagp/mxpPLz2NImNV79mDX9gY3ZppCZTwChP5qQ==', + ], + Http::STATUS_CREATED, + ], + 'not updated' => [ + 'bb9b52140661ee4f2c31e02ea50a8f67ba353bffc58aa981718f90bd2aa2bd8fc08cad4c0b3ed8f7eb9d79d6a577be75d084bbeb963da1ad74d9279e0014e472', + $this->devicePublicKey, + 'https://push-notifications.nextcloud.com/', + true, + 42, + true, + false, + [ + 'publicKey' => $this->userPublicKey, + 'deviceIdentifier' => 'x9vSImcGjhzR9BfZ/XbbUqqCCNC4bHKsX7vkQWNZRd1/MiY+OuF02fx8K08My0RpkNnwj/rQ/gVSU1oEdFwkww==', + 'signature' => 'J9AcdJt5youJmMnBhS+Cc9ytArynIKtCRoNf/m0oOFO/e0hWHqs1NRdQBe81qzYIjf0+bj0Q97X9Xv1rnVJesPkQUbGaa4nAPt+viGSfvzTptjX4LKgqm8B3UkduBA262IcaWgM5P84gUqelkQIC1nIqq/MJTuC6oQ5lUwIV1a92ZurDjhwH4b3f7/ZLTTOTRD0DWN9W/yOyF1qECivgePR3eu+mkcBzXVU/TDZDJic9G7xhqcTnWV6qk+aKyzdNo1tu5W7mF+v5vF6rrGZrq55vPLWAHApTD7P+NFV01BnaCuN7/qGJNVs7m7EH03jpOw7y3jqNMmcmonYrJSMVqg==', + ], + Http::STATUS_OK, + ], + ]; + } + + /** + * @dataProvider dataRegisterDevice + * + * @param string $pushTokenHash + * @param string $devicePublicKey + * @param string $proxyServer + * @param bool $userIsValid + * @param int $tokenId + * @param bool $tokenIsValid + * @param bool $deviceCreated + * @param array $payload + * @param int $status + */ + public function testRegisterDevice($pushTokenHash, $devicePublicKey, $proxyServer, $userIsValid, $tokenId, $tokenIsValid, $deviceCreated, $payload, $status) { + $controller = $this->getController([ + 'savePushToken', + ]); + + $user = $this->createMock(IUser::class); + if ($userIsValid) { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn($user); + } else { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn(null); + } + + $this->session->expects($tokenId > 0 ? $this->once() : $this->never()) + ->method('get') + ->with('token-id') + ->willReturn($tokenId); + + if ($tokenIsValid) { + $token = $this->createMock(IToken::class); + $token->expects($this->once()) + ->method('getId') + ->willReturn($tokenId); + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willReturn($token); + + $key = $this->createMock(Key::class); + $key->expects($this->once()) + ->method('getPrivate') + ->willReturn($this->userPrivateKey); + $key->expects($this->once()) + ->method('getPublic') + ->willReturn($this->userPublicKey); + + $this->identityProof->expects($this->once()) + ->method('getKey') + ->with($user) + ->willReturn($key); + + $controller->expects($this->once()) + ->method('savePushToken') + ->with($user, $token, $this->anything(), $devicePublicKey, $pushTokenHash, $proxyServer) + ->willReturn($deviceCreated); + } else { + $controller->expects($this->never()) + ->method('savePushToken'); + + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new InvalidTokenException()); + } + + $response = $controller->registerDevice($pushTokenHash, $devicePublicKey, $proxyServer); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame($status, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + } + + public function dataRemoveDevice() { + return [ + 'not authenticated' => [ + false, + 0, + false, + null, + [], + Http::STATUS_UNAUTHORIZED + ], + 'invalid token' => [ + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'using production' => [ + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'created or updated' => [ + true, + 23, + true, + true, + [], + Http::STATUS_ACCEPTED, + ], + 'not updated' => [ + true, + 42, + true, + false, + [], + Http::STATUS_OK, + ], + ]; + } + + + /** + * @dataProvider dataRemoveDevice + * + * @param bool $userIsValid + * @param int $tokenId + * @param bool $tokenIsValid + * @param bool $deviceDeleted + * @param array $payload + * @param int $status + */ + public function testRemoveDevice($userIsValid, $tokenId, $tokenIsValid, $deviceDeleted, $payload, $status) { + $controller = $this->getController([ + 'deletePushToken', + ]); + + $user = $this->createMock(IUser::class); + if ($userIsValid) { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn($user); + } else { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn(null); + } + + $this->session->expects($tokenId > 0 ? $this->once() : $this->never()) + ->method('get') + ->with('token-id') + ->willReturn($tokenId); + + if ($tokenIsValid) { + $token = $this->createMock(IToken::class); + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willReturn($token); + + $controller->expects($this->once()) + ->method('deletePushToken') + ->with($user, $token) + ->willReturn($deviceDeleted); + } else { + $controller->expects($this->never()) + ->method('deletePushToken'); + + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new InvalidTokenException()); + } + + $response = $controller->removeDevice(); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame($status, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + } + +} diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php new file mode 100644 index 000000000..6399b375b --- /dev/null +++ b/tests/Unit/PushTest.php @@ -0,0 +1,487 @@ + + * @author Thomas Müller + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OCA\Notifications\Tests\Unit; + + +use OC\Authentication\Exceptions\InvalidTokenException; +use OC\Authentication\Token\IProvider; +use OC\Security\IdentityProof\Key; +use OC\Security\IdentityProof\Manager; +use OCA\Notifications\Push; +use OCP\AppFramework\Http; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IResponse; +use OCP\IConfig; +use OCP\Http\Client\IClientService; +use OCP\IDBConnection; +use OCP\ILogger; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\Notification\INotification; + +/** + * Class PushTest + * + * @package OCA\Notifications\Tests\Unit + * @group DB + */ +class PushTest extends TestCase { + /** @var IDBConnection */ + protected $db; + /** @var INotificationManager|\PHPUnit_Framework_MockObject_MockObject */ + protected $notificationManager; + /** @var IConfig|\PHPUnit_Framework_MockObject_MockObject */ + protected $config; + /** @var IProvider|\PHPUnit_Framework_MockObject_MockObject */ + protected $tokenProvider; + /** @var Manager|\PHPUnit_Framework_MockObject_MockObject */ + protected $keyManager; + /** @var IUserManager|\PHPUnit_Framework_MockObject_MockObject */ + protected $userManager; + /** @var IClientService|\PHPUnit_Framework_MockObject_MockObject */ + protected $clientService; + /** @var ILogger|\PHPUnit_Framework_MockObject_MockObject */ + protected $logger; + + protected function setUp() { + parent::setUp(); + + $this->db = \OC::$server->getDatabaseConnection(); + $this->notificationManager = $this->createMock(INotificationManager::class); + $this->config = $this->createMock(IConfig::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->keyManager = $this->createMock(Manager::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->clientService = $this->createMock(IClientService::class); + $this->logger = $this->createMock(ILogger::class); + } + + /** + * @param string[] $methods + * @return Push|\PHPUnit_Framework_MockObject_MockObject + */ + protected function getPush(array $methods = []) { + if (!empty($methods)) { + return $this->getMockBuilder(Push::class) + ->setConstructorArgs([ + $this->db, + $this->notificationManager, + $this->config, + $this->tokenProvider, + $this->keyManager, + $this->userManager, + $this->clientService, + $this->logger, + ]) + ->setMethods($methods) + ->getMock(); + } + + return new Push( + $this->db, + $this->notificationManager, + $this->config, + $this->tokenProvider, + $this->keyManager, + $this->userManager, + $this->clientService, + $this->logger + ); + } + + public function testPushToDeviceInvalidUser() { + $push = $this->getPush(); + $this->keyManager->expects($this->never()) + ->method('getKey'); + $this->clientService->expects($this->never()) + ->method('newClient'); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->once()) + ->method('getUser') + ->willReturn('invalid'); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('invalid') + ->willReturn(null); + + $push->pushToDevice($notification); + } + + public function testPushToDeviceNoDevices() { + $push = $this->getPush(['getDevicesForUser']); + $this->keyManager->expects($this->never()) + ->method('getKey'); + $this->clientService->expects($this->never()) + ->method('newClient'); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->exactly(2)) + ->method('getUser') + ->willReturn('valid'); + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getDevicesForUser') + ->willReturn([]); + + $push->pushToDevice($notification); + } + + public function testPushToDeviceNotPrepared() { + $push = $this->getPush(['getDevicesForUser']); + $this->keyManager->expects($this->never()) + ->method('getKey'); + $this->clientService->expects($this->never()) + ->method('newClient'); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->exactly(3)) + ->method('getUser') + ->willReturn('valid'); + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getDevicesForUser') + ->willReturn([[ + 'proxyserver' => 'proxyserver1', + 'token' => 'token1', + ]]); + + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('valid', 'core', 'lang', 'en') + ->willReturn('de'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'de') + ->willThrowException(new \InvalidArgumentException()); + + $push->pushToDevice($notification); + } + + public function testPushToDeviceInvalidToken() { + $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken']); + $this->clientService->expects($this->never()) + ->method('newClient'); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->exactly(3)) + ->method('getUser') + ->willReturn('valid'); + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getDevicesForUser') + ->willReturn([[ + 'proxyserver' => 'proxyserver1', + 'token' => 23, + ]]); + + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('valid', 'core', 'lang', 'en') + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + + /** @var Key|\PHPUnit_Framework_MockObject_MockObject $key */ + $key = $this->createMock(Key::class); + + $this->keyManager->expects($this->once()) + ->method('getKey') + ->with($user) + ->willReturn($key); + + $push->expects($this->once()) + ->method('encryptAndSign') + ->willThrowException(new InvalidTokenException()); + + $push->expects($this->once()) + ->method('deletePushToken') + ->with(23); + + $push->pushToDevice($notification); + } + + public function testPushToDeviceEncryptionError() { + $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken']); + $this->clientService->expects($this->never()) + ->method('newClient'); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->exactly(3)) + ->method('getUser') + ->willReturn('valid'); + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getDevicesForUser') + ->willReturn([[ + 'proxyserver' => 'proxyserver1', + 'token' => 23, + ]]); + + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('valid', 'core', 'lang', 'en') + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + + /** @var Key|\PHPUnit_Framework_MockObject_MockObject $key */ + $key = $this->createMock(Key::class); + + $this->keyManager->expects($this->once()) + ->method('getKey') + ->with($user) + ->willReturn($key); + + $push->expects($this->once()) + ->method('encryptAndSign') + ->willThrowException(new \InvalidArgumentException()); + + $push->expects($this->once()) + ->method('deletePushToken') + ->with(23); + + $push->pushToDevice($notification); + } + + public function dataPushToDeviceSending() { + return [ + [true], + [false], + ]; + } + + /** + * @dataProvider dataPushToDeviceSending + * @param bool $isDebug + */ + public function testPushToDeviceSending($isDebug) { + $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken']); + + /** @var INotification|\PHPUnit_Framework_MockObject_MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->exactly(3)) + ->method('getUser') + ->willReturn('valid'); + + /** @var IUser|\PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + + $this->userManager->expects($this->once()) + ->method('get') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getDevicesForUser') + ->willReturn([ + [ + 'proxyserver' => 'proxyserver1', + 'token' => 16, + ], + [ + 'proxyserver' => 'proxyserver1/', + 'token' => 23, + ], + [ + 'proxyserver' => 'badrequest', + 'token' => 42, + ], + [ + 'proxyserver' => 'unavailable', + 'token' => 48, + ], + [ + 'proxyserver' => 'ok', + 'token' => 64, + ], + ]); + + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('valid', 'core', 'lang', 'en') + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + /** @var Key|\PHPUnit_Framework_MockObject_MockObject $key */ + $key = $this->createMock(Key::class); + + $this->keyManager->expects($this->once()) + ->method('getKey') + ->with($user) + ->willReturn($key); + + $push->expects($this->exactly(5)) + ->method('encryptAndSign') + ->willReturn(['Payload']); + + $push->expects($this->never()) + ->method('deletePushToken'); + + /** @var IClient|\PHPUnit_Framework_MockObject_MockObject $client */ + $client = $this->createMock(IClient::class); + + $this->clientService->expects($this->once()) + ->method('newClient') + ->willReturn($client); + + $e = new \Exception(); + $client->expects($this->at(0)) + ->method('post') + ->with('proxyserver1/notifications', [ + 'body' => [ + 'notifications' => ['["Payload"]', '["Payload"]'], + ], + ]) + ->willThrowException($e); + + $this->logger->expects($this->at(0)) + ->method('logException') + ->with($e, [ + 'app' => 'notifications', + ]); + + /** @var IResponse|\PHPUnit_Framework_MockObject_MockObject $response1 */ + $response1 = $this->createMock(IResponse::class); + $response1->expects($this->once()) + ->method('getStatusCode') + ->willReturn(Http::STATUS_BAD_REQUEST); + $response1->expects($this->once()) + ->method('getBody') + ->willReturn(null); + $client->expects($this->at(1)) + ->method('post') + ->with('badrequest/notifications', [ + 'body' => [ + 'notifications' => ['["Payload"]'], + ], + ]) + ->willReturn($response1); + + $this->logger->expects($this->at(1)) + ->method('error') + ->with('Could not send notification to push server [{url}]: {error}', [ + 'error' => 'no reason given', + 'url' => 'badrequest', + 'app' => 'notifications', + ]); + + /** @var IResponse|\PHPUnit_Framework_MockObject_MockObject $response1 */ + $response2 = $this->createMock(IResponse::class); + $response2->expects($this->once()) + ->method('getStatusCode') + ->willReturn(Http::STATUS_SERVICE_UNAVAILABLE); + $response2->expects($isDebug ? $this->once() : $this->never()) + ->method('getBody') + ->willReturn('Maintenance'); + $client->expects($this->at(2)) + ->method('post') + ->with('unavailable/notifications', [ + 'body' => [ + 'notifications' => ['["Payload"]'], + ], + ]) + ->willReturn($response2); + + $this->config->expects($this->once()) + ->method('getSystemValue') + ->with('debug', false) + ->willReturn($isDebug); + + $this->logger->expects($isDebug ? $this->at(2) : $this->never()) + ->method('debug') + ->with('Could not send notification to push server [{url}]: {error}', [ + 'error' => 'Maintenance', + 'url' => 'unavailable', + 'app' => 'notifications', + ]); + + /** @var IResponse|\PHPUnit_Framework_MockObject_MockObject $response1 */ + $response3 = $this->createMock(IResponse::class); + $response3->expects($this->once()) + ->method('getStatusCode') + ->willReturn(Http::STATUS_OK); + $client->expects($this->at(3)) + ->method('post') + ->with('ok/notifications', [ + 'body' => [ + 'notifications' => ['["Payload"]'], + ], + ]) + ->willReturn($response3); + + $push->pushToDevice($notification); + } +}