diff --git a/.vscode/settings.json b/.vscode/settings.json index b4183a5..de27e26 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "files.exclude": { - "vendor": true + "vendor": true, + "**/docs": true } } \ No newline at end of file diff --git a/src/ClientApplication/Cli/Command/AbstractCommand.php b/src/ClientApplication/Cli/Command/AbstractCommand.php index 1d8a1f0..abb2266 100644 --- a/src/ClientApplication/Cli/Command/AbstractCommand.php +++ b/src/ClientApplication/Cli/Command/AbstractCommand.php @@ -17,16 +17,26 @@ abstract class AbstractCommand extends Command public const OPTION_APP_NAME = 'app-name'; public const OPTION_ENVIRONMENT = 'env'; public const OPTION_IGNORE_CACHE = 'ignore-cache'; + public const OPTION_LOCAL_DIRECTORY_PATH = 'local-dir-path'; /** @var string */ private $env; + /** @var AwsSdk */ + private $awsSdk; + + /** @var CacheItemPoolInterface */ + private $psrCache; + /** @var boolean */ private $useCache = true; /** @var DataLoader */ private $dataLoader; + /** @var string */ + private $localDirPath; + /** * Constructs the command * @@ -40,7 +50,8 @@ public function __construct(string $env, AwsSdk $awsSdk, CacheItemPoolInterface { parent::__construct(); $this->env = $env; - $this->dataLoader = new DataLoader($env, $awsSdk, $psrCache); + $this->awsSdk = $awsSdk; + $this->psrCache = $psrCache; } /** @@ -53,7 +64,7 @@ protected function configure() self::OPTION_ENVIRONMENT, null, InputOption::VALUE_REQUIRED, - "Application environment. One of `dev`, `test` or `production`.\n\n" . + "Application environment. One of `dev`, `test` or `production`. " . "Overrides the value extracted from the runtime environment itself." ) ->addOption( @@ -61,13 +72,24 @@ protected function configure() null, InputOption::VALUE_NONE, "Ignores cached configuration data and always uses data fetched from S3." + ) + ->addOption( + self::OPTION_LOCAL_DIRECTORY_PATH, + null, + InputOption::VALUE_REQUIRED, + "Read application data files from a local directory. " . + "Intended for testing purposes only. " . + "Overrides the `--" . self::OPTION_IGNORE_CACHE . "` option." ); } /** - * {@inheritdoc} + * Reads common CLI options into class properties + * + * @param InputInterface $input + * @return void */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function getCommonOptions(InputInterface $input): void { if ($input->getOption(self::OPTION_IGNORE_CACHE)) { $this->useCache = false; @@ -75,6 +97,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getOption(self::OPTION_ENVIRONMENT)) { $this->env = $input->getOption(self::OPTION_ENVIRONMENT); } + if ($input->getOption(self::OPTION_LOCAL_DIRECTORY_PATH)) { + $this->localDirPath = $input->getOption(self::OPTION_LOCAL_DIRECTORY_PATH); + } } /** @@ -92,15 +117,18 @@ protected function writeInfoHeader(OutputInterface $output, string $title, array new OutputFormatterStyle('black', 'yellow', ['bold']) ); + $commonHeaderInfo = ['Environment' => $this->getEnv()]; + if ($this->localDirPath === null) { + $commonHeaderInfo['Use cache?'] = ($this->getUseCache() ? "Yes" : "No"); + } else { + $commonHeaderInfo['Local path'] = $this->localDirPath; + $commonHeaderInfo['Local path expanded'] = realpath($this->localDirPath); + } + + $rows = array_merge($commonHeaderInfo, $headerInfo); + $maxLen = ['k' => 0, 'v' => 0]; - $rows = array_merge( - [ - "Environment" => $this->getEnv(), - "Use cache?" => ($this->getUseCache() ? "Yes" : "No") - ], - $headerInfo - ); - $output->writeln(""); + foreach ($rows as $k => $v) { if (strlen($k) > $maxLen['k']) { $maxLen['k'] = strlen($k); @@ -109,6 +137,8 @@ protected function writeInfoHeader(OutputInterface $output, string $title, array $maxLen['v'] = strlen($v); } } + + $output->writeln(""); $output->writeln( "
" . str_pad('', $maxLen['k'] + $maxLen['v'] + 3, '-') . "
" ); @@ -131,7 +161,13 @@ protected function writeInfoHeader(OutputInterface $output, string $title, array protected function getCommonHelpText(): string { return "\nDefaults to using the current runtime environment. This can be overridden with\n" . - "the --" . self::OPTION_ENVIRONMENT . " argument.\n"; + "the --" . self::OPTION_ENVIRONMENT . " argument.\n\n" . + "Defaults to using application data sourced from Amazon S3, and will use cached\n" . + "data (if it exists) unless the `" . self::OPTION_IGNORE_CACHE . "` option is set.\n\n" . + "This behaviour can be overriden by using the `" . self::OPTION_LOCAL_DIRECTORY_PATH . + "` option. This will\n" . + "look for application data in the provided local directory path (use this for testing\n" . + "purposes only - always use Amazon S3 sourced data for live web applications).\n"; } protected function getEnv(): string @@ -146,6 +182,6 @@ protected function getUseCache(): bool protected function getDataLoader(): DataLoader { - return $this->dataLoader; + return new DataLoader($this->getEnv(), $this->awsSdk, $this->psrCache, $this->localDirPath); } } diff --git a/src/ClientApplication/Cli/Command/ConfirmPasswordCommand.php b/src/ClientApplication/Cli/Command/ConfirmPasswordCommand.php index 00f7d7d..a37ded3 100644 --- a/src/ClientApplication/Cli/Command/ConfirmPasswordCommand.php +++ b/src/ClientApplication/Cli/Command/ConfirmPasswordCommand.php @@ -34,7 +34,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - parent::execute($input, $output); + $this->getCommonOptions($input); $appId = $input->getArgument(self::ARG_APP_ID); diff --git a/src/ClientApplication/Cli/Command/ShowMissingCredentialsCommand.php b/src/ClientApplication/Cli/Command/ShowMissingCredentialsCommand.php index 10ef82b..45e060a 100644 --- a/src/ClientApplication/Cli/Command/ShowMissingCredentialsCommand.php +++ b/src/ClientApplication/Cli/Command/ShowMissingCredentialsCommand.php @@ -32,6 +32,6 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - parent::execute($input, $output); + $this->getCommonOptions($input); } } diff --git a/src/ClientApplication/Cli/Command/ViewConfigCommand.php b/src/ClientApplication/Cli/Command/ViewConfigCommand.php index 24df847..a684998 100644 --- a/src/ClientApplication/Cli/Command/ViewConfigCommand.php +++ b/src/ClientApplication/Cli/Command/ViewConfigCommand.php @@ -40,7 +40,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - parent::execute($input, $output); + $this->getCommonOptions($input); $headerInfo = []; diff --git a/src/ClientApplication/Cli/Command/ViewCredentialsCommand.php b/src/ClientApplication/Cli/Command/ViewCredentialsCommand.php index 4108c74..61b626b 100644 --- a/src/ClientApplication/Cli/Command/ViewCredentialsCommand.php +++ b/src/ClientApplication/Cli/Command/ViewCredentialsCommand.php @@ -37,7 +37,7 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - parent::execute($input, $output); + $this->getCommonOptions($input); $credsFile = $this->getDataLoader()->getCredentialsObjectName($this->getEnv()); $headerInfo = ['Credentials file' => $credsFile]; diff --git a/src/ClientApplication/DataLoader.php b/src/ClientApplication/DataLoader.php index 032ad4b..b77f754 100644 --- a/src/ClientApplication/DataLoader.php +++ b/src/ClientApplication/DataLoader.php @@ -23,8 +23,8 @@ class DataLoader private const ENVIRONMENTS = ['dev', 'test', 'production']; private const S3_BUCKET_NAME = 'sws.clientapps'; private const S3_BASE_PATH = 'v2'; - private const S3_COMMON_APP_DATA_NAME = 'apps.json'; - private const S3_ENV_CREDENTIALS_NAME_PATTERN = 'credentials.__env__.json'; + private const COMMON_APP_DATA_NAME = 'apps.json'; + private const ENV_CREDENTIALS_NAME_PATTERN = 'credentials.__env__.json'; private const CREDENTIALS_ENV_PLACEHOLDER = '__env__'; /** @var string */ @@ -39,15 +39,24 @@ class DataLoader /** @var CacheItemPoolInterface */ private $psrCache; + /** @var string */ + private $localDirPath; + /** * Constructs the object * - * @param string $env Application environment - * @param AwsSdk $awsSdk AWS SDK - * @param CacheItemPoolInterface $psrCache PSR-6 cache item pool + * @param string $env Application environment + * @param AwsSdk $awsSdk AWS SDK + * @param CacheItemPoolInterface $psrCache PSR-6 cache item pool + * @param string $localDirPath Path to a directory where configuration files can be found. + * Overrides `$awsSdk` and `$psrCache` parameters. */ - public function __construct(string $env, AwsSdk $awsSdk, CacheItemPoolInterface $psrCache) - { + public function __construct( + string $env, + AwsSdk $awsSdk, + CacheItemPoolInterface $psrCache, + string $localDirPath = null + ) { if (!in_array($env, self::ENVIRONMENTS)) { throw new InvalidEnvironmentNameException( 'Invalid environment name `' . $env . '`. Must be one of `' . @@ -59,6 +68,16 @@ public function __construct(string $env, AwsSdk $awsSdk, CacheItemPoolInterface $this->awsSdk = $awsSdk; $this->psrCache = $psrCache; + if ($localDirPath !== null) { + $this->localDirPath = realpath($localDirPath); + if ($this->localDirPath === false) { + throw new Exception("Invalid directory path '" . $this->localDirPath . "'. Path does not exist."); + } + if (!is_dir($this->localDirPath)) { + throw new Exception("Invalid directory path '" . $this->localDirPath . "'. Path is not a directory."); + } + } + // Load all environment data in `dev` environment if ($this->env === 'dev') { $this->loadEnv = self::ENVIRONMENTS; @@ -83,7 +102,7 @@ public function getApp(string $env = null, bool $useCache = true): array $credentialsObject = $this->getCredentialsObjectName($env); return $this->mergeCredentials( - $this->getItem(self::S3_COMMON_APP_DATA_NAME, $useCache), + $this->getItem(self::COMMON_APP_DATA_NAME, $useCache), $this->getItem($credentialsObject, $useCache), $credentialsObject ); @@ -95,6 +114,56 @@ public function getApp(string $env = null, bool $useCache = true): array * @return array */ public function getItem(string $name, bool $useCache = true): array + { + if ($this->localDirPath !== null) { + return $this->loadFromLocalDirectory($name); + } else { + return $this->loadFromCache($name, $useCache); + } + } + + /** + * Returns the name of an environment-specific credentials object + * + * @param string $env + * @return string + */ + public function getCredentialsObjectName(string $env): string + { + return str_replace( + self::CREDENTIALS_ENV_PLACEHOLDER, + $env, + self::ENV_CREDENTIALS_NAME_PATTERN + ); + } + + /** + * Load application data from a file in a local directory. + * + * @return array + */ + private function loadFromLocalDirectory(string $name): array + { + $filePath = rtrim($this->localDirPath, '/') . '/' . $name; + if (file_exists($filePath)) { + $data = json_decode((string)file_get_contents($filePath), true); + if ($data === null) { + throw new Exception("Invalid file path '$filePath'. File does not contain valid JSON."); + } else { + return $data; + } + } else { + throw new Exception("Invalid file path '$filePath'. File does not exist."); + } + } + + /** + * Load application data from cache if available. + * If not, fetch from S3 and save to cache. + * + * @return array + */ + private function loadFromCache(string $name, bool $useCache = true): array { $s3ObjectName = self::S3_BASE_PATH . '/' . $name; @@ -118,23 +187,8 @@ public function getItem(string $name, bool $useCache = true): array $item->set($s3Data); $item->expiresAt($expiryTime); $this->psrCache->save($item); - - return $s3Data; - } - /** - * Returns the name of an environment-specific credentials object - * - * @param string $env - * @return string - */ - public function getCredentialsObjectName(string $env): string - { - return str_replace( - self::CREDENTIALS_ENV_PLACEHOLDER, - $env, - self::S3_ENV_CREDENTIALS_NAME_PATTERN - ); + return $s3Data; } /** diff --git a/tests/ClientApplication/DataLoaderTest.php b/tests/ClientApplication/DataLoaderTest.php index 2b98548..bff5c03 100644 --- a/tests/ClientApplication/DataLoaderTest.php +++ b/tests/ClientApplication/DataLoaderTest.php @@ -4,6 +4,7 @@ use Aws\Sdk; use Serato\SwsApp\ClientApplication\DataLoader; use Serato\SwsApp\Test\TestCase; +use Exception; class DataLoaderTest extends TestCase { @@ -22,7 +23,7 @@ public function testMalformedAppData() { $dataLoader = new DataLoader( 'dev', - $this->getAwsSdk($this->getAwsMockResponses('apps.malformed.json', 'credentials.json')), + $this->getAwsSdk($this->getAwsMockResponses('apps.malformed.json', 'credentials.dev.json')), $this->getFileSystemCachePool() ); $dataLoader->getApp(); @@ -71,11 +72,109 @@ public function testSuccessfulLoad() { $dataLoader = new DataLoader( 'dev', - $this->getAwsSdk($this->getAwsMockResponses('apps.json', 'credentials.json')), + $this->getAwsSdk($this->getAwsMockResponses('apps.json', 'credentials.dev.json')), $this->getFileSystemCachePool() ); - $appData = $dataLoader->getApp(); + $this->assertValidAppData($dataLoader->getApp()); + } + + public function testLocalDirInvalidDirPath() + { + try { + new DataLoader( + 'dev', + $this->getAwsSdk(), + $this->getFileSystemCachePool(), + './no-such-directory' + ); + // Shouldn't get this far + $this->assertTrue(false); + } catch (Exception $e) { + $this->assertTrue(strpos($e->getMessage(), 'Path does not exist') !== false); + } + } + + public function testLocalDirSpecifyFilePath() + { + try { + new DataLoader( + 'dev', + $this->getAwsSdk(), + $this->getFileSystemCachePool(), + rtrim(__DIR__, '/') . '/data/apps.json' + ); + // Shouldn't get this far + $this->assertTrue(false); + } catch (Exception $e) { + $this->assertTrue(strpos($e->getMessage(), 'Path is not a directory') !== false); + } + } + + public function testLocalDirInvalidJsonFiles() + { + try { + $dataLoader = new DataLoader( + 'dev', + $this->getAwsSdk(), + $this->getFileSystemCachePool(), + rtrim(__DIR__, '/') . '/data/local_malformed' + ); + $dataLoader->getApp(); + // Shouldn't get this far + $this->assertTrue(false); + } catch (Exception $e) { + $this->assertTrue(strpos($e->getMessage(), 'File does not contain valid JSON') !== false); + } + } + + public function testLocalDirInvalidFilesDontExist() + { + try { + $dataLoader = new DataLoader( + 'dev', + $this->getAwsSdk(), + $this->getFileSystemCachePool(), + rtrim(__DIR__, '/') . '/data/local_does_not_exist' + ); + $dataLoader->getApp(); + // Shouldn't get this far + $this->assertTrue(false); + } catch (Exception $e) { + $this->assertTrue(strpos($e->getMessage(), 'File does not exist') !== false); + } + } + + public function testLocalDirValidFiles() + { + $dataLoader = new DataLoader( + 'dev', + $this->getAwsSdk(), + $this->getFileSystemCachePool(), + rtrim(__DIR__, '/') . '/data' + ); + $this->assertValidAppData($dataLoader->getApp()); + } + + /** + * Creates an array of mock AWS Result objects. + * + * The array contains two items corresponding to S3 GetObject + * requests for the `apps.json` file and `credentials.dev.json` file. + * + * @return array + */ + private function getAwsMockResponses(string $appsFileName, string $credentialsFileName): array + { + # FYI `Serato\SwsApp\ClientApplication\DataLoader` always loads the apps file first + return [ + ['Body' => file_get_contents(__DIR__ . '/data/' . $appsFileName)], + ['Body' => file_get_contents(__DIR__ . '/data/' . $credentialsFileName)] + ]; + } + + private function assertValidAppData(array $appData): void + { # Make sure that the correct number of apps are loaded $this->assertEquals(4, count($appData)); @@ -96,27 +195,10 @@ public function testSuccessfulLoad() $this->assertTrue(isset($appData['App4']['jwt'])); $this->assertTrue(isset($appData['App4']['jwt']['access']['permissioned_scopes'])); - # 5. App 5 should no be present in data (it's defined in `apps.json` but not `credentials.json`) + # 5. App 5 should no be present in data (it's defined in `apps.json` but not `credentials.dev.json`) $this->assertFalse(isset($appData['App5'])); - # 6. App 6 should no be present in data (it's defined in `credentials.json` but not `apps.json`) + # 6. App 6 should no be present in data (it's defined in `credentials.dev.json` but not `apps.json`) $this->assertFalse(isset($appData['App6'])); } - - /** - * Creates an array of mock AWS Result objects. - * - * The array contains two items corresponding to S3 GetObject - * requests for the `apps.json` file and `credentials.json` file. - * - * @return array - */ - private function getAwsMockResponses(string $appsFileName, string $credentialsFileName): array - { - # FYI `Serato\SwsApp\ClientApplication\DataLoader` always loads the apps file first - return [ - ['Body' => file_get_contents(__DIR__ . '/data/' . $appsFileName)], - ['Body' => file_get_contents(__DIR__ . '/data/' . $credentialsFileName)] - ]; - } } diff --git a/tests/ClientApplication/data/credentials.json b/tests/ClientApplication/data/credentials.dev.json similarity index 100% rename from tests/ClientApplication/data/credentials.json rename to tests/ClientApplication/data/credentials.dev.json diff --git a/tests/ClientApplication/data/local_does_not_exist/z_apps.json b/tests/ClientApplication/data/local_does_not_exist/z_apps.json new file mode 100644 index 0000000..3558d2e --- /dev/null +++ b/tests/ClientApplication/data/local_does_not_exist/z_apps.json @@ -0,0 +1,111 @@ +{ + "App1" : { + "name" : "Application 1", + "description" : "Application with JWT params basic default scopes", + "jwt" : { + "access" : { + "default_audience" : ["license.serato.io"], + "default_scopes" : { + "license.serato.io" : ["user-license", "user-license-activation"] + }, + "expires" : 3600 + }, + "refresh" : { + "expires" : 31536000 + } + } + }, + "App2" : { + "name" : "Application 2", + "description" : "Some as Application, but credentials adds `restricted_to` settings into JWT access params", + "jwt" : { + "access" : { + "default_audience" : ["license.serato.io"], + "default_scopes" : { + "license.serato.io" : ["user-license", "user-license-activation"] + }, + "expires" : 3600 + }, + "refresh" : { + "expires" : 31536000 + } + } + }, + "App3": { + "name" : "Application 3", + "description" : "Application with no JWT token params", + "scopes": { + "profile.serato.com": ["profile-edit-admin"] + } + }, + "App4" : { + "name" : "Application 4", + "description" : "Application with JWT params and lots default and permissioned scopes", + "jwt" : { + "access" : { + "default_audience" : ["id.serato.io", "license.serato.io", "ecom.serato.com", "profile.serato.com"], + "default_scopes" : { + "license.serato.io" : ["user-license", "user-license-activation"], + "id.serato.io" : ["user-get", "user-update"], + "ecom.serato.com": ["user-read", "user-write"], + "profile.serato.com": ["profile-edit"] + }, + "permissioned_scopes" : { + "license.serato.io": { + "user-license-admin": [ + ["Root"], + ["Serato", "Support"], + ["Serato", "License Admin"] + ] + }, + "id.serato.io": { + "user-admin": [ + ["Root"], + ["Serato", "Support"] + ] + }, + "ecom.serato.com": { + "admin-user-read": [ + ["Root"], + ["Serato", "Support"] + ] + }, + "profile.serato.com": { + "profile-edit-admin": [ + ["Root"], + ["Serato", "Support"] + ] + } + }, + "expires" : 3600 + }, + "refresh" : { + "expires" : 31536000 + } + }, + "seas": true + }, + "App5" : { + "name" : "Application 5", + "description": "No credentials for this app in `credentials.json`", + "scopes": { + "license.serato.io": ["app-license-admin"] + }, + "jwt" : { + "access" : { + "default_audience" : ["id.serato.io", "license.serato.io", "ecom.serato.com", "profile.serato.com"], + "default_scopes" : { + "license.serato.io" : ["user-license", "user-license-activation"], + "id.serato.io" : ["user-get", "user-update"], + "ecom.serato.com": ["user-read", "user-write"], + "profile.serato.com": ["profile-edit"] + }, + "expires" : 3600, + "restricted_to": ["Serato"] + }, + "refresh" : { + "expires" : 31536000 + } + } + } +} diff --git a/tests/ClientApplication/data/local_does_not_exist/z_credentials.dev.json b/tests/ClientApplication/data/local_does_not_exist/z_credentials.dev.json new file mode 100644 index 0000000..9720eb0 --- /dev/null +++ b/tests/ClientApplication/data/local_does_not_exist/z_credentials.dev.json @@ -0,0 +1,32 @@ +{ + "App1" : { + "id" : "254687b4-3602-46f5-a223-56140f061cea", + "password_description" : "Password is `password_for_app_1`", + "password_hash" : "$2y$10$ER9EnHOkm9sqd7enSqad4.MMPsHFu7SoY.imoBCjAD.0YIcQshcxa", + "kms_key_id" : "c4258ade-003a-4b45-b121-d96ebbd1235c" + }, + "App2": { + "id" : "b478760f-7e54-4f3a-9ac1-d48d3d92852b", + "password_description" : "Password is `password_for_app_2`", + "password_hash" : "$2y$10$0B/3fAMg8AFM/dKKTdwIPOoycM9A4n0GhjXLJPVKvTISnwxFEGcIy", + "kms_key_id" : "19b8df78-cd9c-409d-9759-9998e6bae53d", + "restricted_to": ["Wailshark Private Beta", "Serato", "Wailshark Internal Users"] + }, + "App3": { + "id" : "3138cd15-4d8a-4c4b-a611-45d65e4e5c57", + "password_description" : "Password is `password_for_app_3`", + "password_hash" : "$2y$10$HL4278QFNnjNIzvA6v7/fOywquB/BvjKD3xUDey7zzEEWvWXmy4TK" + }, + "App4" : { + "id" : "546b5cc5-26a0-461c-9037-9ceb4e3a8180", + "password_description" : "Password is `password_for_app_4`", + "password_hash" : "$2y$10$A39JYEPJGbadq0PoLONkzOB88i7Azc27okiXV0uoHyRwL.VoLXWt6", + "kms_key_id" : "7cf7dffd-cf43-468e-880e-db4ff4903688" + }, + "App6" : { + "id" : "36a3d43e-9bd9-4536-802f-bacf71b04041", + "password_description" : "Password is `password_for_app_6`", + "password_hash" : "$2y$10$VBgE1fxgxi5zJCkkezuq1.QJtr.JhHRIkZTtRfzT2pVCV.SpdFQyi", + "kms_key_id" : "02bc169a-afa9-437c-a44b-3ed8f1e97f42" + } +} diff --git a/tests/ClientApplication/data/local_malformed/apps.json b/tests/ClientApplication/data/local_malformed/apps.json new file mode 100644 index 0000000..dbff2e5 --- /dev/null +++ b/tests/ClientApplication/data/local_malformed/apps.json @@ -0,0 +1 @@ +{"not":"valid", "json} \ No newline at end of file diff --git a/tests/ClientApplication/data/local_malformed/credentials.dev.json b/tests/ClientApplication/data/local_malformed/credentials.dev.json new file mode 100644 index 0000000..dbff2e5 --- /dev/null +++ b/tests/ClientApplication/data/local_malformed/credentials.dev.json @@ -0,0 +1 @@ +{"not":"valid", "json} \ No newline at end of file