diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..32a1e27 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f1ca35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/coverage/ +/vendor/ +/composer.lock \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a5b9754 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: php + +php: + - '5.6' + - '7.0' + +before_script: + - composer self-update + - composer install --no-interaction --prefer-source --dev + +script: ./vendor/bin/phpunit --configuration phpunit.xml.dist --coverage-text --coverage-clover=coverage.clover diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..178685a --- /dev/null +++ b/LICENCE @@ -0,0 +1,3 @@ +CC BY-NC-SA + +See https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode for more informations. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d60b8e4 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# `La Presse Libre` Client Library + +Unofficial PHP SDK for the project [La Presse Libre](https://github.com/NextINpact/LaPresseLibreSDK). The difference with the official package offered by NextINpact is compatibility with PSR4, PSR7 and php7 environment. + +## Usage + +```php +$account_always_exists = function ($data, $is_testing) use ($public_key) { + $now = new DateTime('next year'); + + return [ + 'Mail' => $data['Mail'], + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'TypeAbonnement' => SubscriptionType::MONTHLY, + 'DateExpiration' => $now->format("Y-m-d\TH:i:sO"), + 'DateSouscription' => $now->format("Y-m-d\TH:i:sO"), + 'AccountExist' => true, + 'PartenaireID' => $public_key, + ]; +}; +$verification = Endpoint::answer(Verification::class, $account_always_exists); +``` + +Detailed examples for each endpoints are available : + +- [exemples/verification.php](exemples/verification.php) +- [exemples/account-creation.php](exemples/account-creation.php) +- [exemples/account-update.php](exemples/account-update.php) +- [exemples/register.php](exemples/register.php) + +## Installation + +Simply install this package with [Composer](http://getcomposer.org/). + +```bash +composer require mediapart/lapresselibre +``` + +## Read More + +- Official `La Presse Libre` [documentation](https://github.com/NextINpact/LaPresseLibreSDK/wiki/) (fr). \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2f75d6e --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "mediapart/lapresselibre", + "type": "library", + "description": "Unofficial Client API of La Presse Libre project", + "license": "CC BY-NC-SA", + "authors": [ + { + "name": "Thomas GASC", + "email": "thomas.gasc@mediapart.fr" + } + ], + "autoload": { + "psr-4": { + "Mediapart\\LaPresseLibre\\": "src/", + "Mediapart\\LaPresseLibre\\Tests\\": "tests/" + } + }, + "require": { + "php": "^5.6|^7.0", + "ext-json": "*", + "ext-openssl": "*", + "ext-hash": "*", + "psr/http-message": "^1.0", + "psr/log": "^1.0", + "symfony/options-resolver": "^2.8|^3.3" + }, + "require-dev": { + "zendframework/zend-diactoros": "^1.4", + "phpunit/phpunit": "^5.7|^6.3" + }, + "suggest": { + "monolog/monolog": "Allows more advanced logging of the application flow" + } +} diff --git a/exemples/account-creation.php b/exemples/account-creation.php new file mode 100644 index 0000000..d3a7fcf --- /dev/null +++ b/exemples/account-creation.php @@ -0,0 +1,60 @@ + true, + 'PartenaireID' => $public_key, + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'CodeEtat' => AccountCreation::SUCCESS, + ]; + }; + $account_creation = Endpoint::answer(AccountCreation::class, $account_always_created); + $result = $transaction->process($account_creation); + $status = 200; +} catch (\InvalidArgumentException $e) { + $result = $e->getMessage(); + $status = 400; +} catch (\UnexpectedValueException $e) { + $result = $e->getMessage(); + $status = 401; +} catch (\Exception $e) { + $result = 'Internal Error'; + $status = 500; +} finally { + $response = new Response( + 200 != $status ? json_encode(['error' => $result]) : $result, + $status, + [ + 'X-PART' => (string) $public_key, + 'X-LPL' => $identity->sign($public_key), + 'X-TS' => (string) $identity->getDatetime()->getTimestamp(), + ] + ); +} + +$emitter = new SapiEmitter(); +$emitter->emit($response); diff --git a/exemples/account-update.php b/exemples/account-update.php new file mode 100644 index 0000000..96f5f8e --- /dev/null +++ b/exemples/account-update.php @@ -0,0 +1,55 @@ +process($account_update); + $status = 200; +} catch (\InvalidArgumentException $e) { + $result = $e->getMessage(); + $status = 400; +} catch (\UnexpectedValueException $e) { + $result = $e->getMessage(); + $status = 401; +} catch (\Exception $e) { + $result = 'Internal Error'; + $status = 500; +} finally { + $response = new Response( + 200 != $status ? json_encode(['error' => $result]) : $result, + $status, + [ + 'X-PART' => (string) $public_key, + 'X-LPL' => $identity->sign($public_key), + 'X-TS' => (string) $identity->getDatetime()->getTimestamp(), + ] + ); +} + +$emitter = new SapiEmitter(); +$emitter->emit($response); diff --git a/exemples/register.php b/exemples/register.php new file mode 100644 index 0000000..10c1192 --- /dev/null +++ b/exemples/register.php @@ -0,0 +1,14 @@ +generateLink('username@domain.tld', 'username'); + +echo 'register into La Presse Libre'; diff --git a/exemples/verification.php b/exemples/verification.php new file mode 100644 index 0000000..3dc5057 --- /dev/null +++ b/exemples/verification.php @@ -0,0 +1,66 @@ + $data['Mail'], + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'TypeAbonnement' => SubscriptionType::MONTHLY, + 'DateExpiration' => $now->format("Y-m-d\TH:i:sO"), + 'DateSouscription' => $now->format("Y-m-d\TH:i:sO"), + 'AccountExist' => true, + 'PartenaireID' => $public_key, + ]; + }; + $verification = Endpoint::answer(Verification::class, $account_always_exists); + $result = $transaction->process($verification); + $status = 200; +} catch (\InvalidArgumentException $e) { + $result = $e->getMessage(); + $status = 400; +} catch (\UnexpectedValueException $e) { + $result = $e->getMessage(); + $status = 401; +} catch (\Exception $e) { + $result = 'Internal Error'; + $status = 500; +} finally { + $response = new Response( + 200 != $status ? json_encode(['error' => $result]) : $result, + $status, + [ + 'X-PART' => (string) $public_key, + 'X-LPL' => $identity->sign($public_key), + 'X-TS' => (string) $identity->getDatetime()->getTimestamp(), + ] + ); +} + +$emitter = new SapiEmitter(); +$emitter->emit($response); diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..bbb9543 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + + ./src + + ./vendor + ./tests + + + + + \ No newline at end of file diff --git a/src/Endpoint.php b/src/Endpoint.php new file mode 100644 index 0000000..0dd45eb --- /dev/null +++ b/src/Endpoint.php @@ -0,0 +1,104 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\NullLogger; + +abstract class Endpoint implements LoggerAwareInterface +{ + use \Psr\Log\LoggerAwareTrait; + + /** + * @var callable + */ + protected $callback; + + /** + * Construct a new Endpoint to answer one of the API expected operation + * + * @param string $operation Class name of the Endpoint + * @param callable $callback + * + * @return Endpoint + * @throws \LogicException if $operation is not a child of Endpoint + */ + public static function answer($operation, $callback) + { + if (!in_array(self::class, class_parents($operation))) { + throw new \LogicException(sprintf( + '%s is not a child of %s', + $operation, + self::class + )); + } + + return new $operation($callback); + } + + /** + * @param callable $callback + * + * @throws \InvalidArgumentException if the callback is not callable. + */ + private function __construct($callback) + { + $this->callback = $callback; + $this->logger = new NullLogger(); + + if (!is_callable($callback)) { + throw new \InvalidArgumentException('Create endpoint with invalid callback'); + } + } + + /** + * Execute the endpoint. + * + * @param array $data + * @param bool $isTesting + * + * @return mixed + */ + public function execute($data, $isTesting = false) + { + $arguments = $this->resolveInput($data); + $response = call_user_func($this->callback, $arguments, $isTesting); + $result = $this->resolveOutput($response); + + $this->logger->debug( + sprintf('Executed endpoint %s', get_class($this)), + [$arguments, $result, $isTesting] + ); + + return $result; + } + + /** + * Verify and complete the expected input values + * when calling the web service. + * + * @param array $arguments + * + * @return array Arguments. + */ + abstract protected function resolveInput(array $arguments); + + /** + * Verify and complete the expected output values + * when calling the web service. + * + * @param array $arguments + * + * @return array Data + */ + abstract protected function resolveOutput(array $data); +} diff --git a/src/Operation/AccountChange.php b/src/Operation/AccountChange.php new file mode 100644 index 0000000..5e9bcaf --- /dev/null +++ b/src/Operation/AccountChange.php @@ -0,0 +1,38 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Operation; + +use Mediapart\LaPresseLibre\Endpoint; + +abstract class AccountChange extends Endpoint +{ + /** + * `CodeEtat` output argument could have the following values : + * + * @var int + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#param%C3%A8tres-de-sortie-1 + */ + const SUCCESS = 1; + const FAILED_EXISTING_EMAIL = 2; + const FAILED_EXISTING_USERNAME = 3; + const FAILED = 4; + + public static function allStates() + { + return [ + self::SUCCESS, + self::FAILED_EXISTING_EMAIL, + self::FAILED_EXISTING_USERNAME, + self::FAILED, + ]; + } +} diff --git a/src/Operation/AccountCreation.php b/src/Operation/AccountCreation.php new file mode 100644 index 0000000..62105a9 --- /dev/null +++ b/src/Operation/AccountCreation.php @@ -0,0 +1,70 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Operation; + +use Mediapart\LaPresseLibre\Subscription\Status as SubscriptionStatus; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#web-service-de-cr%C3%A9ation-de-comptes + */ +class AccountCreation extends AccountChange +{ + /** + * {@inheritdoc} + */ + protected function resolveInput(array $arguments) + { + $resolver = new OptionsResolver(); + $resolver->setRequired([ + 'Pseudo', + 'Mail', + 'Password', + 'TypeAbonnement', + 'DateSouscription', + 'DateExpiration', + 'Tarif', + 'CodeUtilisateur', + 'Statut', + ]); + + $resolver->setAllowedTypes('Tarif', 'float'); + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + $resolver->setAllowedTypes('Statut', 'int'); + $resolver->setAllowedValues('Statut', SubscriptionStatus::all()); + + return $resolver->resolve($arguments); + } + + /** + * {@inheritdoc} + */ + protected function resolveOutput(array $data) + { + $resolver = new OptionsResolver(); + $resolver->setRequired([ + 'IsValid', + 'PartenaireID', + 'CodeUtilisateur', + 'CodeEtat', + ]); + + $resolver->setAllowedTypes('IsValid', 'bool'); + $resolver->setAllowedTypes('PartenaireID', 'int'); + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + $resolver->setAllowedTypes('CodeEtat', 'int'); + $resolver->setAllowedValues('CodeEtat', self::allStates()); + + return $resolver->resolve($data); + } +} diff --git a/src/Operation/AccountUpdate.php b/src/Operation/AccountUpdate.php new file mode 100644 index 0000000..e9d0d42 --- /dev/null +++ b/src/Operation/AccountUpdate.php @@ -0,0 +1,69 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Operation; + +use Mediapart\LaPresseLibre\Subscription\Type as SubscriptionType; +use Mediapart\LaPresseLibre\Subscription\Status as SubscriptionStatus; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#web-service-de-mise-%C3%A0-jour-de-comptes + */ +class AccountUpdate extends AccountChange +{ + /** + * {@inheritdoc} + */ + protected function resolveInput(array $arguments) + { + $resolver = new OptionsResolver(); + $resolver->setRequired([ + 'CodeUtilisateur', + 'TypeAbonnement', + 'DateSouscription', + 'DateExpiration', + 'Tarif', + 'Statut', + ]); + + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + $resolver->setAllowedTypes('TypeAbonnement', 'string'); + $resolver->setAllowedTypes('Tarif', 'float'); + $resolver->setAllowedTypes('Statut', 'int'); + $resolver->setAllowedValues('TypeAbonnement', SubscriptionType::all()); + $resolver->setAllowedValues('Statut', SubscriptionStatus::all()); + + return $resolver->resolve($arguments); + } + + /** + * {@inheritdoc} + */ + protected function resolveOutput(array $data) + { + $resolver = new OptionsResolver(); + $resolver->setRequired([ + 'IsValid', + 'PartenaireID', + 'CodeUtilisateur', + 'CodeEtat', + ]); + + $resolver->setAllowedTypes('IsValid', 'bool'); + $resolver->setAllowedTypes('PartenaireID', 'int'); + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + $resolver->setAllowedTypes('CodeEtat', 'int'); + $resolver->setAllowedValues('CodeEtat', self::allStates()); + + return $resolver->resolve($data); + } +} diff --git a/src/Operation/Verification.php b/src/Operation/Verification.php new file mode 100644 index 0000000..76d6f81 --- /dev/null +++ b/src/Operation/Verification.php @@ -0,0 +1,65 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Operation; + +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Subscription\Type as SubscriptionType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#web-service-de-v%C3%A9rification-de-comptes-existants + */ +class Verification extends Endpoint +{ + /** + * {@inheritdoc} + */ + protected function resolveInput(array $arguments) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'Mail' => null, + 'Password' => null, + ]); + + $resolver->setRequired('CodeUtilisateur'); + $resolver->setAllowedTypes('Mail', ['string', 'null']); + $resolver->setAllowedTypes('Password', ['string', 'null']); + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + + return $resolver->resolve($arguments); + } + + /** + * {@inheritdoc} + */ + protected function resolveOutput(array $data) + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'TypeAbonnement' => null, + 'DateExpiration' => null, + 'DateSouscription' => null, + ]); + + $resolver->setRequired(['Mail', 'CodeUtilisateur', 'AccountExist', 'PartenaireID']); + $resolver->setAllowedTypes('Mail', 'string'); + $resolver->setAllowedTypes('CodeUtilisateur', 'string'); + $resolver->setAllowedValues('TypeAbonnement', array_merge(SubscriptionType::all(), [null])); + $resolver->setAllowedTypes('DateExpiration', ['string', 'null']); + $resolver->setAllowedTypes('DateSouscription', ['string', 'null']); + $resolver->setAllowedTypes('AccountExist', ['boolean']); + $resolver->setAllowedTypes('PartenaireID', 'int'); + + return $resolver->resolve($data); + } +} diff --git a/src/Registration.php b/src/Registration.php new file mode 100644 index 0000000..b40aad6 --- /dev/null +++ b/src/Registration.php @@ -0,0 +1,72 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre; + +use Mediapart\LaPresseLibre\Security\Encryption; + +class Registration +{ + /** + * @var string Route where your user will register into La Presse Libre platform. + */ + const LINK_ROUTE = 'https://www.lapresselibre.fr/inscription-partenaire'; + + /** + * @var int + */ + private $public_key; + + /** + * @var Encryption + */ + private $encryption; + + /** + * @param int $public_key + * @param Encryption $encryption + */ + public function __construct($public_key, Encryption $encryption) + { + $this->public_key = $public_key; + $this->encryption = $encryption; + } + + /** + * Generate the link that allows you former users to + * register themselves into La Presse Libre platform. + * + * @param string $email + * @param string $userName + * @param string $guid + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Int%C3%A9gration-et-configuration-du-SDK#inscription-%C3%A0-la-presse-libre-depuis-une-plateforme-partenaire + * + * @return string + */ + public function generateLink($email, $userName, $guid = null) + { + $user = $this->encryption->encrypt( + [ + 'Email' => $email, + 'Pseudo' => $userName, + 'Guid' => $guid, + ], + OPENSSL_RAW_DATA & OPENSSL_NO_PADDING + ); + + return sprintf( + '%s?user=%s&partId=%s', + self::LINK_ROUTE, + rawurlencode($user), + $this->public_key + ); + } +} diff --git a/src/Security/Encryption.php b/src/Security/Encryption.php new file mode 100644 index 0000000..4d8c44b --- /dev/null +++ b/src/Security/Encryption.php @@ -0,0 +1,109 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Security; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\NullLogger; + +/** + * Used to encrypt/decrypt messages has described in the API specifications. + * + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#g%C3%A9n%C3%A9ralit%C3%A9s + */ +class Encryption implements LoggerAwareInterface +{ + use \Psr\Log\LoggerAwareTrait; + + /** + * @var string + */ + private $password; + + /** + * @var int + */ + private $iv; + + /** + * @var int + */ + private $options = 0; + + /** + * @var string + */ + private $method = 'AES-256-CBC'; + + /** + * @param mixed $password + * @param mixed $iv + * @param int $options + */ + public function __construct($password, $iv = null, $options = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING) + { + if (null == $iv) { + $iv_len = openssl_cipher_iv_length($this->method); + $iv = openssl_random_pseudo_bytes($iv_len); + } + + $this->password = $password; + $this->iv = $iv; + $this->options = $options; + $this->logger = new NullLogger(); + } + + /** + * @param string $message + * + * @return string Crypted message + */ + public function encrypt($message, $options = null) + { + $options = !is_null($options) ? $options : $this->options; + $result = json_encode($message); + $result = openssl_encrypt( + $result, + $this->method, + $this->password, + $options, + $this->iv + ); + $result = base64_encode($result); + + $this->logger->debug('Encrypting message', [$message, $result]); + + return $result; + } + + /** + * @param string $message Crypted message + * + * @return string Uncrypted message + */ + public function decrypt($message, $options = null) + { + $options = !is_null($options) ? $options : $this->options; + $result = base64_decode($message); + $result = openssl_decrypt( + $result, + $this->method, + $this->password, + $options, + $this->iv + ); + $result = json_decode(rtrim($result, "\0"), true); + + $this->logger->debug('Uncrypting message', [$message, $result]); + + return $result; + } +} diff --git a/src/Security/Identity.php b/src/Security/Identity.php new file mode 100644 index 0000000..8380faf --- /dev/null +++ b/src/Security/Identity.php @@ -0,0 +1,85 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Security; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\NullLogger; + +/** + * Used to sign documents has described in the API specifications. + * + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#g%C3%A9n%C3%A9ralit%C3%A9s + */ +class Identity implements LoggerAwareInterface +{ + use \Psr\Log\LoggerAwareTrait; + + /** + * @var string Name of the hashing algorithm + */ + const HASH_ALGORITHM = 'SHA1'; + + /** + * @var string Pattern used to concatenate public/secret key and timestamp + */ + const SIGNATURE_PATTERN = '%s+%s+%s'; + + /** + * @var string + */ + private $secret_key; + + /** + * @var \Datetime + */ + protected $datetime; + + /** + * @param string $secret_key + */ + public function __construct($secret_key) + { + $this->secret_key = $secret_key; + $this->datetime = new \DateTime(); + $this->logger = new NullLogger(); + } + + /** + * Generate a signature based on your private key. + * + * @param string $public_key + * @param int $timestamp + * + * @return string + */ + public function sign($public_key, $timestamp = null) + { + $timestamp = $timestamp ? $timestamp : $this->datetime->getTimestamp(); + $signchain = sprintf(self::SIGNATURE_PATTERN, $public_key, $timestamp, $this->secret_key); + $signature = hash(self::HASH_ALGORITHM, $signchain); + + $this->logger->debug( + 'Generated signature', + [$public_key, $timestamp, $signature] + ); + + return $signature; + } + + /** + * @return \Datetime + */ + public function getDatetime() + { + return $this->datetime; + } +} diff --git a/src/Subscription/Status.php b/src/Subscription/Status.php new file mode 100644 index 0000000..7df8b20 --- /dev/null +++ b/src/Subscription/Status.php @@ -0,0 +1,42 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Subscription; + +/** + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#statut-de-labonnement + */ +final class Status +{ + const ACTIVE = 1; + const WAITING_FOR_VALIDATION = 2; + const SUSPENDED = 3; + const TERMINATED = 4; + const CANCELED = 5; + const IN_PAYMENT = 6; + const REFUNDED = 7; + + /** + * @return Array All subscription statuses. + */ + public static function all() + { + return [ + self::ACTIVE, + self::WAITING_FOR_VALIDATION, + self::SUSPENDED, + self::TERMINATED, + self::CANCELED, + self::IN_PAYMENT, + self::REFUNDED, + ]; + } +} diff --git a/src/Subscription/Type.php b/src/Subscription/Type.php new file mode 100644 index 0000000..1ed93e3 --- /dev/null +++ b/src/Subscription/Type.php @@ -0,0 +1,31 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Subscription; + +final class Type +{ + const MONTHLY = 'mensuel'; + const ANNUAL = 'annuel'; + const OTHER = 'autre'; + + /** + * @return Array All subscription type. + */ + public static function all() + { + return [ + self::MONTHLY, + self::ANNUAL, + self::OTHER, + ]; + } +} diff --git a/src/Transaction.php b/src/Transaction.php new file mode 100644 index 0000000..ab0094b --- /dev/null +++ b/src/Transaction.php @@ -0,0 +1,131 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\NullLogger; +use Psr\Http\Message\RequestInterface as Request; +use Mediapart\LaPresseLibre\Security\Identity; +use Mediapart\LaPresseLibre\Security\Encryption; + +/** + * @see https://github.com/NextINpact/LaPresseLibreSDK/wiki/Fonctionnement-des-web-services#g%C3%A9n%C3%A9ralit%C3%A9s + */ +class Transaction implements LoggerAwareInterface +{ + use \Psr\Log\LoggerAwareTrait; + + /** + * @var Request + */ + private $request; + + /** + * @var Encryption + */ + private $encryption; + + /** + * Initiate a new transaction. + * + * @param int $secret + * @param Encryption $encryption + * @param Request $request + * + * To initiate a transaction, Request should have the following headers : + * + * - X-PART (int32): partner identifier code + * - X-TS (timestamp): sending transaction datetime + * - X-LPL (string): hashed signature + * + * @throws \InvalidArgumentException if one of these headers are missing + * @throws \UnexpectedValueException if one of these headers has invalid value + */ + public function __construct(Identity $identity, Encryption $encryption, Request $request) + { + $this->request = $request; + $this->encryption = $encryption; + $this->logger = new NullLogger(); + + $signature = $identity->sign( + $this->requireHeader('X-PART'), + $this->requireHeader('X-TS') + ); + + if ($signature != $this->requireHeader('X-LPL')) { + throw new \UnexpectedValueException(sprintf( + 'Request signed by %s but expected %s', + $request->getHeaderLine('X-LPL'), + $signature + )); + } + } + + /** + * @param string $name + * + * @throws InvalidArgumentException + * + * @return mixed + */ + private function requireHeader($name) + { + if (!$this->request->hasHeader($name)) { + throw new \InvalidArgumentException(sprintf( + 'Missing header %s', + $name + )); + } + + return $this->request->getHeaderLine($name); + } + + /** + * Returns if the transaction is in testing mode. + * + * If true, the endpoint should returns valid response but without applying + * its effetcs on database. + * Based on the X-CTX HTTP request header. + * + * @return bool returns true if testing mode is active, false otherwise + */ + public function isTesting() + { + return (bool) $this->request->hasHeader('X-CTX'); + } + + /** + * Execute an endpoint and returns his result encrypted. + * + * @param Endpoint $endpoint + * + * @return string + */ + public function process(Endpoint $endpoint) + { + if (in_array($this->request->getMethod(), ['PUT', 'POST'])) { + $input = $this->request->getBody(); + } else { + parse_str($this->request->getUri()->getQuery(), $query); + $input = $query['crd']; + } + + $data = $this->encryption->decrypt($input); + $this->logger->debug('receive data', [$input, $data]); + $result = $endpoint->execute($data, $this->isTesting()); + + $noPaddingOption = OPENSSL_RAW_DATA & OPENSSL_NO_PADDING; + $encryptedResult = $this->encryption->encrypt($result, $noPaddingOption); + + return $encryptedResult; + } +} diff --git a/tests/EndpointTest.php b/tests/EndpointTest.php new file mode 100644 index 0000000..018e970 --- /dev/null +++ b/tests/EndpointTest.php @@ -0,0 +1,40 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Operation\Verification; + +class EndpointTest extends TestCase +{ + public function testAnswerWithInvalidOperation() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(sprintf( + '%s is not a child of %s', + \stdClass::class, + Endpoint::class + )); + + $endpoint = Endpoint::answer(\stdClass::class, function () { + }); + } + + public function testAnswerWithInvalidCallback() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Create endpoint with invalid callback'); + + $endpoint = Endpoint::answer(Verification::class, 42); + } +} diff --git a/tests/Operation/AccountCreationTest.php b/tests/Operation/AccountCreationTest.php new file mode 100644 index 0000000..fcf2e96 --- /dev/null +++ b/tests/Operation/AccountCreationTest.php @@ -0,0 +1,53 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Subscription\Status; +use Mediapart\LaPresseLibre\Operation\AccountCreation; + +class AccountCreationTest extends TestCase +{ + public function testExecute() + { + $public_key = 2; + $callback = function ($data, $isTesting) use ($public_key) { + return [ + 'IsValid' => true, + 'PartenaireID' => $public_key, + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'CodeEtat' => AccountCreation::SUCCESS, + ]; + }; + + $endpoint = Endpoint::answer(AccountCreation::class, $callback); + $result = $endpoint->execute([ + 'Pseudo' => 'pseudo', + 'Mail' => 'pseudo@domain.tld', + 'Password' => 'pass', + 'TypeAbonnement' => 'mensuel', + 'DateSouscription' => '', + 'DateExpiration' => '', + 'Tarif' => 9.90, + 'CodeUtilisateur' => 'aaaa-bbbb-2222', + 'Statut' => Status::ACTIVE, + ]); + + $this->assertEquals([ + 'IsValid' => true, + 'PartenaireID' => $public_key, + 'CodeUtilisateur' => 'aaaa-bbbb-2222', + 'CodeEtat' => AccountCreation::SUCCESS, + ], $result); + } +} diff --git a/tests/Operation/AccountUpdateTest.php b/tests/Operation/AccountUpdateTest.php new file mode 100644 index 0000000..72c2e7c --- /dev/null +++ b/tests/Operation/AccountUpdateTest.php @@ -0,0 +1,51 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Operation\AccountUpdate; +use Mediapart\LaPresseLibre\Subscription\Type as SubscriptionType; +use Mediapart\LaPresseLibre\Subscription\Status as SubscriptionStatus; + +class AccountUpdateTest extends TestCase +{ + public function testExecute() + { + $public_key = 2; + $callback = function ($data, $isTesting) use ($public_key) { + return [ + 'IsValid' => true, + 'PartenaireID' => $public_key, + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'CodeEtat' => AccountUpdate::SUCCESS, + ]; + }; + + $endpoint = Endpoint::answer(AccountUpdate::class, $callback); + $result = $endpoint->execute([ + 'CodeUtilisateur' => 'aaaa-bbbb-2222', + 'TypeAbonnement' => SubscriptionType::MONTHLY, + 'DateSouscription' => '', + 'DateExpiration' => '', + 'Tarif' => 9.90, + 'Statut' => SubscriptionStatus::ACTIVE, + ]); + + $this->assertEquals([ + 'IsValid' => true, + 'PartenaireID' => $public_key, + 'CodeUtilisateur' => 'aaaa-bbbb-2222', + 'CodeEtat' => AccountUpdate::SUCCESS, + ], $result); + } +} diff --git a/tests/Operation/VerificationTest.php b/tests/Operation/VerificationTest.php new file mode 100644 index 0000000..71d47d9 --- /dev/null +++ b/tests/Operation/VerificationTest.php @@ -0,0 +1,47 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Operation\Verification; + +class VerificationTest extends TestCase +{ + public function testExecute() + { + $userMail = 'user@domain.tld'; + $userCode = '42'; + + $callback = function ($data, $isTesting) { + return [ + 'Mail' => $data['Mail'], + 'CodeUtilisateur' => $data['CodeUtilisateur'], + 'AccountExist' => false, + 'PartenaireID' => 1, + ]; + }; + + $endpoint = Endpoint::answer(Verification::class, $callback); + $result = $endpoint->execute(['Mail' => $userMail, 'CodeUtilisateur' => $userCode]); + + $this->assertEquals([ + 'TypeAbonnement' => null, + 'DateExpiration' => null, + 'DateSouscription' => null, + 'Mail' => $userMail, + 'CodeUtilisateur' => $userCode, + 'AccountExist' => false, + 'PartenaireID' => 1, + ], $result); + } +} diff --git a/tests/RegistrationTest.php b/tests/RegistrationTest.php new file mode 100644 index 0000000..b4c6580 --- /dev/null +++ b/tests/RegistrationTest.php @@ -0,0 +1,39 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Registration; +use Mediapart\LaPresseLibre\Security\Encryption; + +class RegistrationTest extends TestCase +{ + public function testGenerateLink() + { + $encryption = $this->createMock(Encryption::class); + + $encryption + ->method('encrypt') + ->with([ + 'Email' => 'user@domain.tld', + 'Pseudo' => 'username', + 'Guid' => null, + ]) + ->willReturn('foobar') + ; + + $registration = new Registration(1, $encryption); + $link = $registration->generateLink('user@domain.tld', 'username'); + + $this->assertEquals(Registration::LINK_ROUTE.'?user=foobar&partId=1', $link); + } +} diff --git a/tests/Security/EncryptionTest.php b/tests/Security/EncryptionTest.php new file mode 100644 index 0000000..7df7692 --- /dev/null +++ b/tests/Security/EncryptionTest.php @@ -0,0 +1,29 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Security\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Security\Encryption; + +class EncryptionTest extends TestCase +{ + public function testEncrypt() + { + $string = 'lorem ipsum dolor'; + $encryption = new Encryption('passphrase'); + + $encrypted = $encryption->encrypt($string); + $decrypted = $encryption->decrypt($string); + + $this->assertEquals($encrypted, $decrypted); + } +} diff --git a/tests/Security/IdentityTest.php b/tests/Security/IdentityTest.php new file mode 100644 index 0000000..1bdee44 --- /dev/null +++ b/tests/Security/IdentityTest.php @@ -0,0 +1,27 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Security\Tests; + +use PHPUnit\Framework\TestCase; +use Mediapart\LaPresseLibre\Security\Identity; + +class IdentityTest extends TestCase +{ + public function testSign() + { + $identity = new Identity('secretkey'); + $signature = $identity->sign('publickey', 1500000000); + + $this->assertEquals('b031e33b78f12e5246b64bd6a17935372ded5bf3', $signature); + $this->assertInstanceOf(\Datetime::class, $identity->getDatetime()); + } +} diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php new file mode 100644 index 0000000..d051ad2 --- /dev/null +++ b/tests/TransactionTest.php @@ -0,0 +1,225 @@ + + * + * For the full license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Mediapart\LaPresseLibre\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\UriInterface as Uri; +use Psr\Http\Message\RequestInterface as Request; +use Mediapart\LaPresseLibre\Transaction; +use Mediapart\LaPresseLibre\Endpoint; +use Mediapart\LaPresseLibre\Security\Identity; +use Mediapart\LaPresseLibre\Security\Encryption; + +class TransactionTest extends TestCase +{ + public function testWithInvalidSignature() + { + $identity = $this->createMock(Identity::class); + $encryption = $this->createMock(Encryption::class); + $request = $this->createMock(Request::class); + + $request + ->method('hasHeader') + ->will($this->returnValueMap([ + ['X-PART', true], + ['X-TS', true], + ['X-LPL', true], + ['X-CTX', false], + ])) + ; + $request + ->method('getHeaderLine') + ->will($this->returnValueMap([ + ['X-PART', 'partnerkey'], + ['X-TS', time()], + ['X-LPL', 'signatureA'], + ])) + ; + + $identity + ->method('sign') + ->willReturn('signatureB') + ; + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Request signed by signatureA but expected signatureB'); + + $transaction = new Transaction($identity, $encryption, $request); + } + + public function testMissingRequiredHeader() + { + $identity = $this->createMock(Identity::class); + $encryption = $this->createMock(Encryption::class); + $request = $this->createMock(Request::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Missing header X-PART'); + + $transaction = new Transaction($identity, $encryption, $request); + } + + public function testIsTesting() + { + $identity = $this->createMock(Identity::class); + $encryption = $this->createMock(Encryption::class); + $request = $this->createMock(Request::class); + + $request + ->method('hasHeader') + ->will($this->returnValueMap([ + ['X-PART', true], + ['X-TS', true], + ['X-LPL', true], + ['X-CTX', false], + ])) + ; + $request + ->method('getHeaderLine') + ->will($this->returnValueMap([ + ['X-PART', 'partnerkey'], + ['X-TS', time()], + ['X-LPL', 'signatureA'], + ])) + ; + + $identity + ->method('sign') + ->willReturn('signatureA') + ; + + $transaction = new Transaction($identity, $encryption, $request); + + $this->assertFalse($transaction->isTesting()); + } + + public function testProcess() + { + $identity = $this->createMock(Identity::class); + $encryption = $this->createMock(Encryption::class); + $request = $this->createMock(Request::class); + $endpoint = $this->createMock(Endpoint::class); + $uri = $this->createMock(Uri::class); + + $request + ->method('hasHeader') + ->will($this->returnValueMap([ + ['X-PART', true], + ['X-TS', true], + ['X-LPL', true], + ])) + ; + $request + ->method('getHeaderLine') + ->will($this->returnValueMap([ + ['X-PART', 'partnerkey'], + ['X-TS', time()], + ['X-LPL', 'signatureA'], + ])) + ; + $identity + ->method('sign') + ->willReturn('signatureA') + ; + + $request + ->method('getUri') + ->willReturn($uri) + ; + $uri + ->method('getQuery') + ->willReturn('crd=input') + ; + $encryption + ->expects($this->once()) + ->method('decrypt') + ->with('input') + ->willReturn('data') + ; + $endpoint + ->method('execute') + ->with('data') + ->willReturn('result') + ; + $encryption + ->expects($this->once()) + ->method('encrypt') + ->with('result') + ->willReturn('blabla') + ; + + $transaction = new Transaction($identity, $encryption, $request); + $result = $transaction->process($endpoint); + + $this->assertEquals('blabla', $result); + } + + public function testProcessFromPost() + { + $identity = $this->createMock(Identity::class); + $encryption = $this->createMock(Encryption::class); + $request = $this->createMock(Request::class); + $endpoint = $this->createMock(Endpoint::class); + + $request + ->method('hasHeader') + ->will($this->returnValueMap([ + ['X-PART', true], + ['X-TS', true], + ['X-LPL', true], + ])) + ; + $request + ->method('getHeaderLine') + ->will($this->returnValueMap([ + ['X-PART', 'lorem'], + ['X-TS', time()], + ['X-LPL', 'toto'], + ])) + ; + $identity + ->method('sign') + ->willReturn('toto') + ; + + $request + ->method('getMethod') + ->willReturn('POST') + ; + $request + ->method('getBody') + ->willReturn('input') + ; + $encryption + ->expects($this->once()) + ->method('decrypt') + ->with('input') + ->willReturn('data') + ; + $endpoint + ->method('execute') + ->with('data') + ->willReturn('result') + ; + $encryption + ->expects($this->once()) + ->method('encrypt') + ->with('result') + ->willReturn('blabla') + ; + + $transaction = new Transaction($identity, $encryption, $request); + $result = $transaction->process($endpoint); + + $this->assertEquals('blabla', $result); + } +}