diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5a1f1b..a3e01a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - php: [7.2, 7.3, 7.4, 8.0] + php: [7.4, 8.0] experimental: [false] include: - php: 7.4 diff --git a/composer.json b/composer.json index 01f9f98..4c49f0a 100644 --- a/composer.json +++ b/composer.json @@ -23,19 +23,21 @@ ] }, "require": { - "php": ">=7.2", - "josegonzalez/dotenv": "^3.2", - "slim/slim": "^3.0", + "php": ">=7.4", + "josegonzalez/dotenv": "~4.0", + "slim/slim": "~3.0", "validator/livr": "dev-master" }, "require-dev": { - "phpunit/phpunit": "^8.5", - "mikey179/vfsstream": "^1.6", - "helmich/phpunit-psr7-assert": "^4.1", - "syberisle/mock-php-stream": "^1.1" + "phpunit/phpunit": "~9.6", + "mikey179/vfsstream": "~1.6", + "helmich/phpunit-psr7-assert": "~4.1", + "syberisle/mock-php-stream": "~1.1", + "phpstan/phpstan": "^1.12" }, "scripts" : { "test": "vendor/bin/phpunit", + "stan": "vendor/bin/phpstan analyze src", "test-coverage": "vendor/bin/phpunit --coverage-clover build/logs/clover.xml" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ff73eff..d8a284c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,22 @@ - - - - - - - ./tests/ - - - - - - ./src/ - - + failOnWarning="true"> + + + ./src/ + + + + + + + + ./tests/ + + diff --git a/src/Action/Api/Scope/Box/Delete.php b/src/Action/Api/Scope/Box/Delete.php index df24c15..36d6124 100644 --- a/src/Action/Api/Scope/Box/Delete.php +++ b/src/Action/Api/Scope/Box/Delete.php @@ -7,6 +7,7 @@ namespace Phagrancy\Action\Api\Scope\Box; +use Phagrancy\Concern\FindsBox; use Phagrancy\Http\Response; use Phagrancy\Model\Input; use Phagrancy\Model\Repository; @@ -19,54 +20,55 @@ */ class Delete { - /** - * @var Repository\Box - */ - private $boxes; + use FindsBox; - /** - * @var string - */ - private $storagePath; + /** + * @var Repository\Box + */ + private $boxes; - /** - * @var Input\BoxDelete - */ - private $input; + /** + * @var string + */ + private $storagePath; - public function __construct(Repository\Box $boxes, Input\BoxDelete $input, $storagePath) - { - $this->boxes = $boxes; - $this->input = $input; - $this->storagePath = $storagePath; - } + /** + * @var Input\BoxDelete + */ + private $input; - public function __invoke(ServerRequestInterface $request) - { - /** - * The route controls these params, and they are validated so safe - * - * @var string $name - * @var string $scope - * @var string $version - * @var string $provider - */ - $params = $this->input->validate($request->getAttribute('route')->getArguments()); - if (!$params) { - return new Response\NotFound(); - } + public function __construct(Repository\Box $boxes, Input\BoxDelete $input, $storagePath) + { + $this->boxes = $boxes; + $this->input = $input; + $this->storagePath = $storagePath; + } - extract($params); - $box = $this->boxes->ofNameInScope($name, $scope); + public function __invoke(ServerRequestInterface $request) + { + /** + * The route controls these params, and they are validated so safe + * + * @var string $name + * @var string $scope + * @var string $version + * @var string $provider + */ + $params = $this->input->validate($request->getAttribute('route')->getArguments()); + if (!$params) { + return new Response\NotFound(); + } - if ($box) { - $path = "{$this->storagePath}/{$box->path()}/{$version}/{$provider}.box"; + $boxPath = $this->findBox($params, $this->storagePath); + if ($boxPath) { + if (is_writable($boxPath) && unlink($boxPath)) { + return new Response\AllClear(); + } + else { + return new Response\Json(['errors' => 'unable to delete'], 409); + } + } - if (file_exists($path) && unlink($path)) { - return new Response\Json([]); - } - } - - return new Response\NotFound(); - } + return new Response\NotFound(); + } } diff --git a/src/Action/Api/Scope/Box/ReturnsUrlForBox.php b/src/Action/Api/Scope/Box/ReturnsUrlForBox.php index fe999cf..647b4b2 100644 --- a/src/Action/Api/Scope/Box/ReturnsUrlForBox.php +++ b/src/Action/Api/Scope/Box/ReturnsUrlForBox.php @@ -27,6 +27,9 @@ protected function createUrlFromRouteParams($params) isset($params['version']) && $url[] = "version/{$params['version']}"; isset($params['provider']) && $url[] = "provider/{$params['provider']}"; + // should we validate the architecture is approved? i386,amd64,aarch64 + isset($params['architecture']) && $url[] = $params['architecture']; + return '/api/v1/box/' . join('/', $url); } } \ No newline at end of file diff --git a/src/Action/Api/Scope/Box/Upload.php b/src/Action/Api/Scope/Box/Upload.php index 2d5b61b..048a806 100644 --- a/src/Action/Api/Scope/Box/Upload.php +++ b/src/Action/Api/Scope/Box/Upload.php @@ -8,8 +8,8 @@ namespace Phagrancy\Action\Api\Scope\Box; use Phagrancy\Http\Response; -use Phagrancy\Model\Input; -use Phagrancy\Model\Repository; +use Phagrancy\Model\Entity\Box; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; /** @@ -18,75 +18,36 @@ * @package Phagrancy\Action\Api\Scope\Box */ class Upload + extends UploadAction { - /** - * @var Repository\Box - */ - private $boxes; - - /** - * @var string - */ - private $uploadPath; - - /** - * @var Input\BoxUpload - */ - private $input; - - public function __construct(Repository\Box $boxes, Input\BoxUpload $input, $uploadPath) - { - $this->boxes = $boxes; - $this->input = $input; - $this->uploadPath = $uploadPath; - } - - public function __invoke(ServerRequestInterface $request) + public function perform(ServerRequestInterface $request, Box $box, $params): ResponseInterface { - /** - * The route controls these params, and they are validated so safe - * - * @var string $name - * @var string $scope - * @var string $version - * @var string $provider - */ - $params = $this->input->validate($request->getAttribute('route')->getArguments()); - if (!$params) { - return new Response\NotFound(); - } - extract($params); - $box = $this->boxes->ofNameInScope($name, $scope); - $path = "{$this->uploadPath}/{$box->path()}/{$version}/"; - // If box with same version and provider already exists prevent overwriting - if (file_exists("$path/{$provider}.box")) { - return new Response\NotFound(); + if (!file_exists("{$this->uploadPath}/tmp")) { + mkdir("{$this->uploadPath}/tmp", 0755, true); } + $tmp = tempnam("{$this->uploadPath}/tmp", 'phagrancy'); - if ($box) { - if (!file_exists("{$this->uploadPath}/tmp")) { - mkdir("{$this->uploadPath}/tmp", 0755, true); - } - $tmp = tempnam("{$this->uploadPath}/tmp", 'phagrancy'); + $request->getBody()->detach(); + $from = fopen("php://input", 'r'); + $to = fopen($tmp, 'w'); - $request->getBody()->detach(); - $from = fopen("php://input", 'r'); - $to = fopen($tmp, 'w'); + stream_copy_to_stream($from, $to); + fclose($from); + fclose($to); - stream_copy_to_stream($from, $to); - fclose($from); - fclose($to); + // make sure it exists + if (!file_exists($path = "{$this->uploadPath}/{$box->path()}/{$version}/")) { + mkdir($path, 0755, true); + } - // make sure it exists - if (!file_exists($path)) { - mkdir($path, 0755, true); - } + // the box name is now {provider}-{architecture}.box, if there is no architecture, then we don't worry + $architecture = $architecture ?? 'unknown'; + $boxPath = "$path/{$provider}-{$architecture}.box"; - rename($tmp, "$path/{$provider}.box"); - } + rename($tmp, $boxPath); - return new Response\Json([]); + return new Response\AllClear(); } } \ No newline at end of file diff --git a/src/Action/Api/Scope/Box/UploadAction.php b/src/Action/Api/Scope/Box/UploadAction.php new file mode 100644 index 0000000..767f67f --- /dev/null +++ b/src/Action/Api/Scope/Box/UploadAction.php @@ -0,0 +1,98 @@ +boxes = $boxes; + $this->input = $input; + $this->uploadPath = $uploadPath; + } + + public function __invoke(ServerRequestInterface $request) + { + $box = $this->validate($request); + + return $box instanceof Response\Json + ? $box + : $this->perform($request, $box, $this->params); + } + + /** + * Not abstract as not all child classes use this method + * + * @param Box $box + * @param $params + * @return ResponseInterface + */ + protected function perform(ServerRequestInterface $request, Box $box, $params): ResponseInterface + { + return new Response\AllClear(); + } + + /** + * Validates the request + * + * @param ServerRequestInterface $request + * @return ResponseInterface|Box + */ + protected function validate(ServerRequestInterface $request) + { + /** + * The route controls these params, and they are validated so safe + * + * @var string $name + * @var string $scope + * @var string $version + * @var string $provider + */ + $this->params = $this->input->validate($request->getAttribute('route')->getArguments()); + if (!$this->params) { + return new Response\NotFound(); + } + + extract($this->params); + $box = $this->boxes->ofNameInScope($name, $scope); + $path = "{$this->uploadPath}/{$box->path()}/{$version}/"; + + // the box name is now {provider}-{architecture}.box, if there is no architecture, then we don't worry + $architecture = $architecture ?? 'unknown'; + $boxPath = "$path/{$provider}-{$architecture}.box"; + + // If box with same version and provider already exists prevent overwriting + if (file_exists($boxPath)) { + return new Response\Json(['errors'=>["box already exists: {$box->path()}/$provider/$architecture"]], 409); + } + + return $box ?? new Response\AllClear(); + } +} \ No newline at end of file diff --git a/src/Action/Api/Scope/Box/UploadConfirm.php b/src/Action/Api/Scope/Box/UploadConfirm.php new file mode 100644 index 0000000..7734778 --- /dev/null +++ b/src/Action/Api/Scope/Box/UploadConfirm.php @@ -0,0 +1,36 @@ +validate($request); + if ($response instanceof Response\Json) { + // box already exists... + return new Response\AllClear(); + } + else { + return new Response\Json(['errors' => ['not uploaded']], 409); + } + } +} \ No newline at end of file diff --git a/src/Action/Api/Scope/Box/UploadDirect.php b/src/Action/Api/Scope/Box/UploadDirect.php new file mode 100644 index 0000000..fddb494 --- /dev/null +++ b/src/Action/Api/Scope/Box/UploadDirect.php @@ -0,0 +1,51 @@ +token = $token; + } + + protected function perform(ServerRequestInterface $request, Box $box, $params): ResponseInterface + { + extract($params); + + $path = $this->createUrlFromRouteParams($request->getAttribute('route')->getArguments()); + $signed = hash_hmac('sha256', "PUT\n$path/upload", $this->token); + $json = [ + 'upload_path' => $request->getUri()->withPath("{$path}/upload") . "?X-Phagrancy-Signature={$signed}", + 'callback' => (string)$request->getUri()->withPath("{$path}/upload/confirm") + ]; + + return new Response\Json($json); + } +} \ No newline at end of file diff --git a/src/Action/Api/Scope/Box/UploadPreFlight.php b/src/Action/Api/Scope/Box/UploadPreFlight.php index 0419e07..6f57dd5 100644 --- a/src/Action/Api/Scope/Box/UploadPreFlight.php +++ b/src/Action/Api/Scope/Box/UploadPreFlight.php @@ -8,6 +8,8 @@ namespace Phagrancy\Action\Api\Scope\Box; use Phagrancy\Http\Response; +use Phagrancy\Model\Entity\Box; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; /** @@ -16,12 +18,13 @@ * @package Phagrancy\Action\Api\Scope\Box */ class UploadPreFlight + extends UploadAction { use ReturnsUrlForBox; - public function __invoke(ServerRequestInterface $request) + protected function perform(ServerRequestInterface $request, Box $box, $params): ResponseInterface { - $path = $this->createUrlFromRouteParams($request->getAttribute('route')->getArguments()); + $path = $this->createUrlFromRouteParams($this->params); $json = [ 'upload_path' => (string)$request->getUri()->withPath("{$path}/upload") ]; diff --git a/src/App.php b/src/App.php index 649826f..d93fde9 100644 --- a/src/App.php +++ b/src/App.php @@ -26,24 +26,32 @@ public function __construct($container = []) // @formatter:off - // packer uses /authenticate to test that the token is valid (must return a 200 when valid) - $this->get('/api/v1/authenticate', Action\AllClear::class) - ->add($container[Middleware\ValidateAccessToken::class]); - // vagrant-cloud/atlas api for uploading new boxes - $this->group('/api/v1/box/{scope}', function () { - $this->get('', Action\Api\Scope\Index::class); - $this->group('/{name}', function () { - $this->get('', Action\Api\Scope\Box\Definition::class); - $this->post('/versions', Action\Api\Scope\Box\CreateVersion::class); - $this->group('/version/{version}', function () { - $this->post('/providers', Action\Api\Scope\Box\CreateProvider::class); - $this->put('/release', Action\AllClear::class); - $this->group('/provider/{provider}', function () { - $this->get('', Action\Api\Scope\Box\SendFile::class); - $this->delete('', Action\Api\Scope\Box\Delete::class); - $this->get('/upload', Action\Api\Scope\Box\UploadPreFlight::class); - $this->put('/upload', Action\Api\Scope\Box\Upload::class); + $this->group('/api/{api_version}', function () { + $this->get('/authenticate', Action\AllClear::class); + $this->group('/box/{scope}', function () { + $this->get('', Action\Api\Scope\Index::class); + $this->group('/{name}', function () { + $this->get('', Action\Api\Scope\Box\Definition::class); + $this->post('/versions', Action\Api\Scope\Box\CreateVersion::class); + $this->group('/version/{version}', function () { + $this->post('/providers', Action\Api\Scope\Box\CreateProvider::class); + $this->put('/release', Action\AllClear::class); + $this->group('/provider/{provider}', function () { + // legacy packer (should this still be supported?) + $this->get('', Action\Api\Scope\Box\SendFile::class); + $this->delete('', Action\Api\Scope\Box\Delete::class); + $this->get('/upload', Action\Api\Scope\Box\UploadPreFlight::class); + $this->put('/upload', Action\Api\Scope\Box\Upload::class); + // modern packer + $this->group('/{architecture}', function() { + $this->delete('', Action\Api\Scope\Box\Delete::class); + $this->put('/upload/confirm', Action\Api\Scope\Box\UploadConfirm::class); + $this->get('/upload/direct', Action\Api\Scope\Box\UploadDirect::class); + $this->get('/upload', Action\Api\Scope\Box\UploadPreFlight::class); + $this->put('/upload', Action\Api\Scope\Box\Upload::class); + }); + }); }); }); }); diff --git a/src/Concern/FindsBox.php b/src/Concern/FindsBox.php new file mode 100644 index 0000000..3562cca --- /dev/null +++ b/src/Concern/FindsBox.php @@ -0,0 +1,29 @@ +boxes->ofNameInScope($name, $scope); + if ($box) { + $architecture = $architecture ?? 'unknown'; + $path = "{$storagePath}/{$box->path()}/{$version}/{$provider}-{$architecture}.box"; + if (file_exists($path)) { + return $path; + } + + if ($architecture === 'unknown') { + $path = "{$storagePath}/{$box->path()}/{$version}/{$provider}.box"; + if (file_exists($path)) { + return $path; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Concern/GeneratesDefinition.php b/src/Concern/GeneratesDefinition.php index c5a2c47..d0917a0 100644 --- a/src/Concern/GeneratesDefinition.php +++ b/src/Concern/GeneratesDefinition.php @@ -36,8 +36,8 @@ public function generateDefinition(Entity\Box $box, UriInterface $uri) $versionedProviders = []; foreach ($providers as $provider) { $versionedProviders[] = [ - 'name' => (string)$provider, - 'url' => (string)$uri->withPath($this->resolveUriPath($box, $version, $provider)) + 'name' => (string)$provider[0], + 'url' => (string)$uri->withPath($this->resolveUriPath($box, $version, join('-', $provider))) ]; } $json['versions'][] = [ diff --git a/src/Http/Middleware/ValidateAccessToken.php b/src/Http/Middleware/ValidateAccessToken.php index 017f5a7..53648bf 100644 --- a/src/Http/Middleware/ValidateAccessToken.php +++ b/src/Http/Middleware/ValidateAccessToken.php @@ -18,7 +18,9 @@ */ class ValidateAccessToken { - use ValidatesToken; + use ValidatesToken { + ValidatesToken::validateToken as private _validateToken; + } public function __construct($token) { @@ -31,4 +33,18 @@ public function __invoke(Request $request, Response $response, $next) ? $next($request, $response) : new NotAuthorized(); } + + protected function validateToken(Request $request) + { + if (preg_match('#/upload$#', $path = $request->getUri()->getPath())) { + if ( + $this->token && + $request->getQueryParam('X-Phagrancy-Signature') === hash_hmac('sha256', "PUT\n$path", $this->token) + ) { + return true; + } + } + + return $this->_validateToken($request); + } } \ No newline at end of file diff --git a/src/Model/Input/BoxDelete.php b/src/Model/Input/BoxDelete.php index 15752b5..73a47c5 100644 --- a/src/Model/Input/BoxDelete.php +++ b/src/Model/Input/BoxDelete.php @@ -24,10 +24,11 @@ public function __construct() { LIVR::registerDefaultRules( [ - 'scope' => [$this, 'validateScope'], - 'name' => [$this, 'validateBoxName'], - 'version' => [$this, 'validateVersion'], - 'provider' => [$this, 'validateProvider'] + 'scope' => [$this, 'validateScope'], + 'name' => [$this, 'validateBoxName'], + 'version' => [$this, 'validateVersion'], + 'provider' => [$this, 'validateProvider'], + 'architecture' => [$this, 'validateArchitecture'], ]); } @@ -36,10 +37,11 @@ public function validate($params) return $this->perform( $params, [ - 'scope' => self::$SCOPE_RULE, - 'name' => self::$BOX_NAME_RULE, - 'version' => self::$VERSION_RULE, - 'provider' => ['required', 'trim', 'to_lc'] + 'scope' => self::$SCOPE_RULE, + 'name' => self::$BOX_NAME_RULE, + 'version' => self::$VERSION_RULE, + 'provider' => ['required', 'trim', 'to_lc'], + 'architecture' => ['trim', 'to_lc'] ]); } } diff --git a/src/Model/Input/BoxUpload.php b/src/Model/Input/BoxUpload.php index bf22668..6ed9afd 100644 --- a/src/Model/Input/BoxUpload.php +++ b/src/Model/Input/BoxUpload.php @@ -12,7 +12,7 @@ /** * Input for the BoxUpload * - * Validates the scope, name, version, provider + * Validates the scope, name, version, provider, architecture * * @package Phagrancy\Model\Input */ @@ -24,10 +24,11 @@ public function __construct() { LIVR::registerDefaultRules( [ - 'scope' => [$this, 'validateScope'], - 'name' => [$this, 'validateBoxName'], - 'version' => [$this, 'validateVersion'], - 'provider' => [$this, 'validateProvider'] + 'scope' => [$this, 'validateScope'], + 'name' => [$this, 'validateBoxName'], + 'version' => [$this, 'validateVersion'], + 'provider' => [$this, 'validateProvider'], + 'architecture' => [$this, 'validateArchitecture'], ]); } @@ -36,10 +37,11 @@ public function validate($params) return $this->perform( $params, [ - 'scope' => self::$SCOPE_RULE, - 'name' => self::$BOX_NAME_RULE, - 'version' => self::$VERSION_RULE, - 'provider' => ['required', 'trim', 'to_lc'] + 'scope' => self::$SCOPE_RULE, + 'name' => self::$BOX_NAME_RULE, + 'version' => self::$VERSION_RULE, + 'provider' => ['required', 'trim', 'to_lc'], + 'architecture' => ['trim', 'to_lc'] ]); } } \ No newline at end of file diff --git a/src/Model/Repository/Box.php b/src/Model/Repository/Box.php index d561f83..4860066 100644 --- a/src/Model/Repository/Box.php +++ b/src/Model/Repository/Box.php @@ -76,7 +76,7 @@ private function loadProviders($dir) foreach (new \FilesystemIterator($dir) as $path => $file) { /** @var $file \SplFileInfo */ if ($file->getExtension() === 'box') { - $providers[] = $file->getBasename('.box'); + $providers[] = explode('-', $file->getBasename('.box')); } } diff --git a/src/ServiceProvider/Pimple.php b/src/ServiceProvider/Pimple.php index 58e32f6..c145056 100644 --- a/src/ServiceProvider/Pimple.php +++ b/src/ServiceProvider/Pimple.php @@ -8,10 +8,10 @@ namespace Phagrancy\ServiceProvider; use josegonzalez\Dotenv\Loader; +use Phagrancy\Action; use Phagrancy\Http\Middleware; use Phagrancy\Model\Input; use Phagrancy\Model\Repository; -use Phagrancy\Action; use Pimple\Container; use Pimple\ServiceProviderInterface; @@ -103,15 +103,52 @@ public function register(Container $di) }; $di[Action\Api\Scope\Box\Upload::class] = function ($c) { - return new Action\Api\Scope\Box\Upload($c[Repository\Box::class], new Input\BoxUpload(), $c['path.storage']); + return new Action\Api\Scope\Box\Upload( + $c[Repository\Box::class], + new Input\BoxUpload(), + $c['path.storage'] + ); }; - $di[Action\Api\Scope\Box\Delete::class] = function ($c) { - return new Action\Api\Scope\Box\Delete($c[Repository\Box::class], new Input\BoxDelete(), $c['path.storage']); - }; + $di[Action\Api\Scope\Box\UploadConfirm::class] = function ($c) { + return new Action\Api\Scope\Box\UploadConfirm( + $c[Repository\Box::class], + new Input\BoxUpload(), + $c['path.storage'] + ); + }; + + $di[Action\Api\Scope\Box\UploadDirect::class] = function ($c) { + return new Action\Api\Scope\Box\UploadDirect( + $c[Repository\Box::class], + new Input\BoxUpload(), + $c['path.storage'], + $c['env']['api_token'] ?? null + ); + }; + + $di[Action\Api\Scope\Box\UploadPreFlight::class] = function ($c) { + return new Action\Api\Scope\Box\UploadPreFlight( + $c[Repository\Box::class], + new Input\BoxUpload(), + $c['path.storage'] + ); + }; + + $di[Action\Api\Scope\Box\Delete::class] = function ($c) { + return new Action\Api\Scope\Box\Delete( + $c[Repository\Box::class], + new Input\BoxDelete(), + $c['path.storage'] + ); + }; $di[Action\Api\Scope\Box\SendFile::class] = function ($c) { - return new Action\Api\Scope\Box\SendFile($c[Repository\Box::class], new Input\BoxUpload(), $c['path.storage']); + return new Action\Api\Scope\Box\SendFile( + $c[Repository\Box::class], + new Input\BoxUpload(), + $c['path.storage'] + ); }; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0fba216 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +fs->url() . '/data/storage/test/something/1.0.0/virtualtest.box')); + self::assertMessageBodyEqualsJsonArray($response, null); + self::assertEquals('upload-data', file_get_contents($this->fs->url() . '/data/storage/test/something/1.0.0/virtualtest-unknown.box')); $response->getBody()->close(); } @@ -196,14 +202,14 @@ public function testUploadReturnsNotFound() } public function testDeleteReturnsNotFoundOnNotExistingBox() - { - $response = $this->runApp( - 'DELETE', - '/api/v1/box/test/something/version/1.0.0/provider/virtualtest' - ); + { + $response = $this->runApp( + 'DELETE', + '/api/v1/box/test/something/version/1.0.0/provider/virtualtest' + ); - self::assertInstanceOf(Response\NotFound::class, $response); - } + self::assertInstanceOf(Response\NotFound::class, $response); + } public function testAccessTokenIsRequired() { @@ -268,4 +274,99 @@ public function testAccessTokenAsHeaderIsInvalid() file_put_contents($env, $old); unset($this->app); } + + public function testArchitectureUpload() + { + \MockPhpStream::register(); + + file_put_contents('php://input', 'arch-data'); + $response = $this->runApp( + 'PUT', + '/api/v1/box/test/something/version/1.0.0/provider/virtualtest/amd64/upload' + ); + + \MockPhpStream::restore(); + + self::assertInstanceOf(Response\Json::class, $response); + self::assertMessageBodyEqualsJsonArray($response, null); + self::assertEquals('arch-data', file_get_contents($this->fs->url() . '/data/storage/test/something/1.0.0/virtualtest-amd64.box')); + + $response->getBody()->close(); + } + + public function testUploadDirectReturnsPathAndCallback() + { + \MockPhpStream::register(); + + file_put_contents('php://input', 'arch-data'); + $response = $this->runApp( + 'GET', + '/api/v1/box/test/something/version/1.0.0/provider/virtualtest/amd64/upload/direct' + ); + + \MockPhpStream::restore(); + + $path = "/api/v1/box/test/something/version/1.0.0/provider/virtualtest/amd64/upload"; + $signature = hash_hmac('sha256', "PUT\n{$path}", null); + self::assertInstanceOf(Response\Json::class, $response); + self::assertMessageBodyEqualsJsonArray($response, [ + 'upload_path' => "http://localhost{$path}?X-Phagrancy-Signature=" . $signature, + 'callback' => "http://localhost{$path}/confirm", + ]); + + $response->getBody()->close(); + } + + public function testDirectUpload() + { + $env = $this->fs->url() . '/.env'; + $old = file_get_contents($env); + file_put_contents($env, "{$old}\napi_token=testing"); + + unset($this->app); + + \MockPhpStream::register(); + + $path = "/api/v1/box/test/direct/version/1.0.0/provider/virtualtest/amd64/upload"; + + file_put_contents('php://input', 'arch-data'); + $response = $this->runApp( + 'PUT', + "{$path}?X-Phagrancy-Signature=" . hash_hmac('sha256', "PUT\n{$path}", 'testing') + ); + + \MockPhpStream::restore(); + + self::assertInstanceOf(Response\Json::class, $response); + self::assertMessageBodyEqualsJsonArray($response, null); + self::assertEquals('arch-data', file_get_contents($this->fs->url() . '/data/storage/test/direct/1.0.0/virtualtest-amd64.box')); + + $response->getBody()->close(); + } + + public function testUploadConfirmReturnsSuccess() + { + $response = $this->runApp( + 'PUT', + '/api/v1/box/arch/test/version/2.0.0/provider/test/amd64/upload/confirm' + ); + + self::assertInstanceOf(Response\Json::class, $response); + self::assertMessageBodyEqualsJsonArray($response, null); + + $response->getBody()->close(); + } + + public function testUploadConfirmReturnsNotUploaded() + { + $response = $this->runApp( + 'PUT', + '/api/v1/box/arch/test/version/3.0.0/provider/test/amd64/upload/confirm' + ); + + self::assertInstanceOf(Response\Json::class, $response); + self::assertMessageBodyEqualsJsonArray($response, ['errors' => ['not uploaded']]); + + $response->getBody()->close(); + } } diff --git a/tests/integration/FrontendTest.php b/tests/integration/FrontendTest.php index 0c67098..19264f7 100644 --- a/tests/integration/FrontendTest.php +++ b/tests/integration/FrontendTest.php @@ -36,7 +36,7 @@ public function provideGoodRoutes() return [ ['GET', '', Response\Json::class, null], ['GET', '/', Response\Json::class, null], - ['GET', '/scopes', Response\ScopeList::class, ['alt', 'delete', 'test']], + ['GET', '/scopes', Response\ScopeList::class, ['alt', 'arch', 'delete', 'test']], ['GET', '/test', Response\BoxList::class, ['username' => 'test', 'boxes' => ['test']]], ['GET', '/test/nope', Response\BoxDefinition::class, ['name' => 'test/nope', 'versions' => []]], [ diff --git a/tests/src/TestCase/Integration.php b/tests/src/TestCase/Integration.php index 8e3f870..9f7ffb7 100644 --- a/tests/src/TestCase/Integration.php +++ b/tests/src/TestCase/Integration.php @@ -30,6 +30,13 @@ abstract class Integration '.env' => 'storage_path=data/storage', 'data' => [ 'storage' => [ + 'arch' => [ + 'test' => [ + '2.0.0' => [ + 'test-amd64.box' => 'test', + ] + ], + ], 'test' => [ 'test' => [ '200' => [ @@ -50,13 +57,18 @@ abstract class Integration ] ] ], - 'delete' => [ - 'test' => [ - '1.0.0' => [ - 'test.box' => 'testcontent' - ] - ] - ] + 'delete' => [ + 'test' => [ + '1.0.0' => [ + 'test.box' => 'testcontent' + ] + ], + 'arch' => [ + '1.0.0' => [ + 'test-arm64.box' => 'testcontent' + ] + ] + ] ] ] ]; diff --git a/tests/unit/Action/Api/Scope/Box/UploadPreFlightTest.php b/tests/unit/Action/Api/Scope/Box/UploadPreFlightTest.php index e8fc3e7..d246d96 100644 --- a/tests/unit/Action/Api/Scope/Box/UploadPreFlightTest.php +++ b/tests/unit/Action/Api/Scope/Box/UploadPreFlightTest.php @@ -8,6 +8,8 @@ namespace Phagrancy\Action\Api\Scope\Box; use Phagrancy\Http\Response\Json; +use Phagrancy\Model\Input\BoxUpload; +use Phagrancy\Model\Repository\Box; use Phagrancy\TestCase\Scope as ScopeTestCase; class UploadPreFlightTest @@ -29,7 +31,12 @@ public function testReturnsOkForExistingBox() protected function runAction($scope, $name, $version, $provider) { - $action = new UploadPreFlight(); + // build the action itself + $action = new UploadPreFlight( + new Box($this->fs->url()), + new BoxUpload(), + $this->fs->url() + ); $request = $this->buildRequest(); $request->getAttribute('route') diff --git a/tests/unit/Action/Api/Scope/Box/UploadTest.php b/tests/unit/Action/Api/Scope/Box/UploadTest.php index 4f970aa..dd06fd0 100644 --- a/tests/unit/Action/Api/Scope/Box/UploadTest.php +++ b/tests/unit/Action/Api/Scope/Box/UploadTest.php @@ -47,22 +47,24 @@ public function testReturnsOkForExistingBox() { $response = $this->uploadBox('uploading'); - self::assertEquals('uploading', file_get_contents($this->fs->url() . '/test/upload/1.0/test.box')); + self::assertEquals('uploading', file_get_contents($this->fs->url() . '/test/upload/1.0/test-unknown.box')); self::assertInstanceOf(Json::class, $response); self::assertResponseHasStatus($response, 200); $response->getBody()->close(); } - public function testReturnsNotFoundForAlreadyUploadedBox() + public function testReturnsErrorForAlreadyUploadedBox() { $this->testReturnsOkForExistingBox(); $response = $this->uploadBox('override box'); - self::assertNotEquals('override box', file_get_contents($this->fs->url() . '/test/upload/1.0/test.box')); + self::assertNotEquals('override box', file_get_contents($this->fs->url() . '/test/upload/1.0/test-unknown.box')); self::assertInstanceOf(Json::class, $response); - self::assertResponseHasStatus($response, 404); + self::assertResponseHasStatus($response, 409); + + // test we get {"errors":["box already exists: test\/upload\/test\/unknown"]} $response->getBody()->close(); }