diff --git a/doc/06-config.md b/doc/06-config.md index b671095a7..76306f9d0 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -141,6 +141,11 @@ used for GitHub Enterprise setups. Defaults to `true`. If `false`, the OAuth tokens created to access the github API will have a date instead of the machine hostname. +## gitlab-domains + +Defaults to `["gitlab.com"]`. A list of domains of GitLab servers. +This is used if you use the `gitlab` repository type. + ## notify-on-install Defaults to `true`. Composer allows repositories to define a notification URL, diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 834598d9e..05ef8bc70 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -44,6 +44,7 @@ class Config 'prepend-autoloader' => true, 'github-domains' => array('github.com'), 'github-expose-hostname' => true, + 'gitlab-domains' => array('gitlab.com'), 'store-auths' => 'prompt', 'platform' => array(), 'archive-format' => 'tar', diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 5c3c549d4..80d7d11e6 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -341,6 +341,7 @@ class Factory $rm->setRepositoryClass('package', 'Composer\Repository\PackageRepository'); $rm->setRepositoryClass('pear', 'Composer\Repository\PearRepository'); $rm->setRepositoryClass('git', 'Composer\Repository\VcsRepository'); + $rm->setRepositoryClass('gitlab', 'Composer\Repository\VcsRepository'); $rm->setRepositoryClass('svn', 'Composer\Repository\VcsRepository'); $rm->setRepositoryClass('perforce', 'Composer\Repository\VcsRepository'); $rm->setRepositoryClass('hg', 'Composer\Repository\VcsRepository'); diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index fed455573..139e58723 100644 --- a/src/Composer/IO/BaseIO.php +++ b/src/Composer/IO/BaseIO.php @@ -70,6 +70,12 @@ abstract class BaseIO implements IOInterface } } + if ($tokens = $config->get('gitlab-oauth')) { + foreach ($tokens as $domain => $token) { + $this->setAuthentication($domain, $token, 'oauth2'); + } + } + // reload http basic credentials from config if available if ($creds = $config->get('http-basic')) { foreach ($creds as $domain => $cred) { diff --git a/src/Composer/Repository/Vcs/GitLabDriver.php b/src/Composer/Repository/Vcs/GitLabDriver.php new file mode 100644 index 000000000..d50ea1f37 --- /dev/null +++ b/src/Composer/Repository/Vcs/GitLabDriver.php @@ -0,0 +1,367 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Repository\Vcs; + +use Composer\Config; +use Composer\Cache; +use Composer\IO\IOInterface; +use Composer\Json\JsonFile; +use Composer\Downloader\TransportException; +use Composer\Util\RemoteFilesystem; +use Composer\Util\GitLab; +/** + * Driver for GitLab API, use the Git driver for local checkouts. + * + * @author Henrik Bjørnskov + * @author Jérôme Tamarelle + */ +class GitLabDriver extends VcsDriver +{ + private $scheme; + private $owner; + private $repository; + + private $cache; + private $infoCache = array(); + + /** + * @var array Project data returned by GitLab API + */ + private $project; + + /** + * @var array Keeps commits returned by GitLab API + */ + private $commits = array(); + + /** + * @var array List of tag => reference + */ + private $tags; + + /** + * @var array List of branch => reference + */ + private $branches; + + /** + * Git Driver + * + * @var GitDriver + */ + protected $gitDriver; + + /** + * Extracts information from the repository url. + * SSH urls uses https by default. + * + * {@inheritDoc} + */ + public function initialize() + { + if (!preg_match('#^((https?)://([^/]+)/|git@([^:]+):)([^/]+)/(.+?)(?:\.git|/)?$#', $this->url, $match)) { + throw new \InvalidArgumentException('The URL provided is invalid. It must be the HTTP URL of a GitLab project.'); + } + + $this->scheme = !empty($match[2]) ? $match[2] : 'https'; + $this->originUrl = !empty($match[3]) ? $match[3] : $match[4]; + $this->owner = $match[5]; + $this->repository = preg_replace('#(\.git)$#', '', $match[6]); + + $this->cache = new Cache($this->io, $this->config->get('cache-repo-dir').'/'.$this->originUrl.'/'.$this->owner.'/'.$this->repository); + + $this->fetchProject(); + } + + /** + * Updates the RemoteFilesystem instance. + * Mainly useful for tests. + * + * @internal + */ + public function setRemoteFilesystem(RemoteFilesystem $remoteFilesystem) + { + $this->remoteFilesystem = $remoteFilesystem; + } + + /** + * Fetches the composer.json file from the project by a identifier. + * + * if specific keys arent present it will try and infer them by default values. + * + * {@inheritDoc} + */ + public function getComposerInformation($identifier) + { + // Convert the root identifier to a cachable commit id + if (!preg_match('{[a-f0-9]{40}}i', $identifier)) { + $branches = $this->getBranches(); + if (isset($branches[$identifier])) { + $identifier = $branches[$identifier]; + } + } + + if (isset($this->infoCache[$identifier])) { + return $this->infoCache[$identifier]; + } + + if (preg_match('{[a-f0-9]{40}}i', $identifier) && $res = $this->cache->read($identifier)) { + return $this->infoCache[$identifier] = JsonFile::parseJson($res, $res); + } + + try { + $composer = $this->fetchComposerFile($identifier); + } catch (TransportException $e) { + if ($e->getCode() !== 404) { + throw $e; + } + $composer = false; + } + + if ($composer && !isset($composer['time']) && isset($this->commits[$identifier])) { + $composer['time'] = $this->commits[$identifier]['committed_date']; + } + + if (preg_match('{[a-f0-9]{40}}i', $identifier)) { + $this->cache->write($identifier, json_encode($composer)); + } + + return $this->infoCache[$identifier] = $composer; + } + + /** + * {@inheritDoc} + */ + public function getRepositoryUrl() + { + return $this->project['ssh_url_to_repo']; + } + + /** + * {@inheritDoc} + */ + public function getUrl() + { + return $this->project['web_url']; + } + + /** + * {@inheritDoc} + */ + public function getDist($identifier) + { + $url = $this->getApiUrl().'/repository/archive.zip?sha='.$identifier; + + return array('type' => 'zip', 'url' => $url, 'reference' => $identifier, 'shasum' => ''); + } + + /** + * {@inheritDoc} + */ + public function getSource($identifier) + { + return array('type' => 'git', 'url' => $this->getRepositoryUrl(), 'reference' => $identifier); + } + + /** + * {@inheritDoc} + */ + public function getRootIdentifier() + { + return $this->project['default_branch']; + } + + /** + * {@inheritDoc} + */ + public function getBranches() + { + if (!$this->branches) { + $this->branches = $this->getReferences('branches'); + } + + return $this->branches; + } + + /** + * {@inheritDoc} + */ + public function getTags() + { + if (!$this->tags) { + $this->tags = $this->getReferences('tags'); + } + + return $this->tags; + } + + /** + * Fetches composer.json file from the repository through api. + * + * @param string $identifier + * + * @return array + */ + protected function fetchComposerFile($identifier) + { + $resource = $this->getApiUrl().'/repository/blobs/'.$identifier.'?filepath=composer.json'; + + return JsonFile::parseJson($this->getContents($resource), $resource); + } + + /** + * @return string Base URL for GitLab API v3 + */ + public function getApiUrl() + { + return $this->scheme.'://'.$this->originUrl.'/api/v3/projects/'.$this->owner.'%2F'.$this->repository; + } + + /** + * @param string $type + * + * @return string[] where keys are named references like tags or branches and the value a sha + */ + protected function getReferences($type) + { + $resource = $this->getApiUrl().'/repository/'.$type; + + $data = JsonFile::parseJson($this->getContents($resource), $resource); + + $references = array(); + + foreach ($data as $datum) { + $references[$datum['name']] = $datum['commit']['id']; + + // Keep the last commit date of a reference to avoid + // unnecessary API call when retrieving the composer file. + $this->commits[$datum['commit']['id']] = $datum['commit']; + } + + return $references; + } + + protected function fetchProject() + { + // we need to fetch the default branch from the api + $resource = $this->getApiUrl(); + $this->project = JsonFile::parseJson($this->getContents($resource, true), $resource); + } + + protected function attemptCloneFallback() + { + + try { + // If this repository may be private and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->setupGitDriver($this->generateSshUrl()); + + return; + } catch (\RuntimeException $e) { + $this->gitDriver = null; + + $this->io->writeError('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your credentials'); + throw $e; + } + } + + protected function setupGitDriver($url) + { + $this->gitDriver = new GitDriver( + array('url' => $url), + $this->io, + $this->config, + $this->process, + $this->remoteFilesystem + ); + $this->gitDriver->initialize(); + } + + /** + * {@inheritDoc} + */ + protected function getContents($url, $fetchingRepoData = false) + { + try { + return parent::getContents($url); + } catch (TransportException $e) { + $gitLabUtil = new GitLab($this->io, $this->config, $this->process, $this->remoteFilesystem); + + switch ($e->getCode()) { + case 401: + case 404: + // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + if (!$fetchingRepoData) { + throw $e; + } + + if ($gitLabUtil->authorizeOAuth($this->originUrl)) { + return parent::getContents($url); + } + + if (!$this->io->isInteractive()) { + return $this->attemptCloneFallback(); + } + $this->io->writeError('Failed to download ' . $this->owner . '/' . $this->repository . ':' . $e->getMessage() . ''); + $gitLabUtil->authorizeOAuthInteractively($this->originUrl, 'Your credentials are required to fetch private repository metadata ('.$this->url.')'); + + return parent::getContents($url); + + case 403: + if (!$this->io->hasAuthentication($this->originUrl) && $gitLabUtil->authorizeOAuth($this->originUrl)) { + return parent::getContents($url); + } + + if (!$this->io->isInteractive() && $fetchingRepoData) { + return $this->attemptCloneFallback(); + } + + throw $e; + + default: + throw $e; + } + } + } + + /** + * Uses the config `gitlab-domains` to see if the driver supports the url for the + * repository given. + * + * {@inheritDoc} + */ + public static function supports(IOInterface $io, Config $config, $url, $deep = false) + { + if (!preg_match('#^((https?)://([^/]+)/|git@([^:]+):)([^/]+)/(.+?)(?:\.git|/)?$#', $url, $match)) { + return false; + } + + $scheme = !empty($match[2]) ? $match[2] : 'https'; + $originUrl = !empty($match[3]) ? $match[3] : $match[4]; + + if (!in_array($originUrl, (array) $config->get('gitlab-domains'))) { + return false; + } + + if ('https' === $scheme && !extension_loaded('openssl')) { + if ($io->isVerbose()) { + $io->write('Skipping GitLab driver for '.$url.' because the OpenSSL PHP extension is missing.'); + } + + return false; + } + + return true; + } +} diff --git a/src/Composer/Repository/VcsRepository.php b/src/Composer/Repository/VcsRepository.php index b6ebd3b35..16ffa3147 100644 --- a/src/Composer/Repository/VcsRepository.php +++ b/src/Composer/Repository/VcsRepository.php @@ -43,6 +43,7 @@ class VcsRepository extends ArrayRepository { $this->drivers = $drivers ?: array( 'github' => 'Composer\Repository\Vcs\GitHubDriver', + 'gitlab' => 'Composer\Repository\Vcs\GitLabDriver', 'git-bitbucket' => 'Composer\Repository\Vcs\GitBitbucketDriver', 'git' => 'Composer\Repository\Vcs\GitDriver', 'hg-bitbucket' => 'Composer\Repository\Vcs\HgBitbucketDriver', diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php index 1d4a98294..e0b9024f9 100644 --- a/src/Composer/Util/Git.php +++ b/src/Composer/Util/Git.php @@ -84,9 +84,11 @@ class Git $bypassSshForGitHub = preg_match('{^git@'.self::getGitHubDomainsRegex($this->config).':(.+?)\.git$}i', $url) && !in_array('ssh', $protocols, true); $command = call_user_func($commandCallable, $url); + if ($bypassSshForGitHub || 0 !== $this->process->execute($command, $ignoredOutput, $cwd)) { // private github repository without git access, try https with auth if (preg_match('{^git@'.self::getGitHubDomainsRegex($this->config).':(.+?)\.git$}i', $url, $match)) { + if (!$this->io->hasAuthentication($match[1])) { $gitHubUtil = new GitHub($this->io, $this->config, $this->process); $message = 'Cloning failed using an ssh key for authentication, enter your GitHub credentials to access private repos'; @@ -99,7 +101,6 @@ class Git if ($this->io->hasAuthentication($match[1])) { $auth = $this->io->getAuthentication($match[1]); $url = 'https://'.rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@'.$match[1].'/'.$match[2].'.git'; - $command = call_user_func($commandCallable, $url); if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) { return; diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php new file mode 100644 index 000000000..499cc492d --- /dev/null +++ b/src/Composer/Util/GitLab.php @@ -0,0 +1,152 @@ + + * + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Composer\IO\IOInterface; +use Composer\Config; +use Composer\Downloader\TransportException; +use Composer\Json\JsonFile; + +/** + * @author Roshan Gautam + */ +class GitLab +{ + protected $io; + protected $config; + protected $process; + protected $remoteFilesystem; + + /** + * Constructor. + * + * @param IOInterface $io The IO instance + * @param Config $config The composer configuration + * @param ProcessExecutor $process Process instance, injectable for mocking + * @param RemoteFilesystem $remoteFilesystem Remote Filesystem, injectable for mocking + */ + public function __construct(IOInterface $io, Config $config, ProcessExecutor $process = null, RemoteFilesystem $remoteFilesystem = null) + { + $this->io = $io; + $this->config = $config; + $this->process = $process ?: new ProcessExecutor(); + $this->remoteFilesystem = $remoteFilesystem ?: new RemoteFilesystem($io, $config); + } + + /** + * Attempts to authorize a GitLab domain via OAuth. + * + * @param string $originUrl The host this GitLab instance is located at + * + * @return bool true on success + */ + public function authorizeOAuth($originUrl) + { + if (!in_array($originUrl, $this->config->get('gitlab-domains'), true)) { + return false; + } + + // if available use token from git config + if (0 === $this->process->execute('git config gitlab.accesstoken', $output)) { + $this->io->setAuthentication($originUrl, trim($output), 'oauth2'); + + return true; + } + + return false; + } + + /** + * Authorizes a GitLab domain interactively via OAuth. + * + * @param string $originUrl The host this GitLab instance is located at + * @param string $message The reason this authorization is required + * + * @throws \RuntimeException + * @throws TransportException|\Exception + * + * @return bool true on success + */ + public function authorizeOAuthInteractively($scheme, $originUrl, $message = null) + { + if ($message) { + $this->io->writeError($message); + } + + $this->io->writeError(sprintf('A token will be created and stored in "%s", your password will never be stored', $this->config->getAuthConfigSource()->getName())); + $this->io->writeError('To revoke access to this token you can visit '.$originUrl.'/profile/applications'); + + $attemptCounter = 0; + + while ($attemptCounter++ < 5) { + try { + $response = $this->createToken($scheme, $originUrl); + } catch (TransportException $e) { + // 401 is bad credentials, + // 403 is max login attempts exceeded + if (in_array($e->getCode(), array(403, 401))) { + if (401 === $e->getCode()) { + $this->io->writeError('Bad credentials.'); + } else { + $this->io->writeError('Maximum number of login attempts exceeded. Please try again later.'); + } + + $this->io->writeError('You can also manually create a personal token at '.$scheme.'://'.$originUrl.'/profile/applications'); + $this->io->writeError('Add it using "composer config gitlab-oauth.'.$originUrl.' "'); + + continue; + } + + throw $e; + } + + $this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2'); + + // store value in user config in auth file + $this->config->getAuthConfigSource()->addConfigSetting('gitlab-oauth.'.$originUrl, $response['access_token']); + + return true; + } + + throw new \RuntimeException('Invalid GitLab credentials 5 times in a row, aborting.'); + } + + private function createToken($scheme, $originUrl) + { + $username = $this->io->ask('Username: '); + $password = $this->io->askAndHideAnswer('Password: '); + + $headers = array('Content-Type: application/x-www-form-urlencoded'); + + $apiUrl = $originUrl; + $data = http_build_query(array( + 'username' => $username, + 'password' => $password, + 'grant_type' => 'password', + )); + $options = array( + 'retry-auth-failure' => false, + 'http' => array( + 'method' => 'POST', + 'header' => $headers, + 'content' => $data, + ), + ); + + $json = $this->remoteFilesystem->getContents($originUrl, $scheme.'://'.$apiUrl.'/oauth/token', false, $options); + + $this->io->writeError('Token successfully created'); + + return JsonFile::parseJson($json); + } +} diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 3c47654b6..fe9c114c1 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -124,6 +124,7 @@ class RemoteFilesystem $originUrl = 'github.com'; } + $this->scheme = parse_url($fileUrl, PHP_URL_SCHEME); $this->bytesMax = 0; $this->originUrl = $originUrl; $this->fileUrl = $fileUrl; @@ -146,20 +147,35 @@ class RemoteFilesystem $options = $this->getOptionsForUrl($originUrl, $additionalOptions); + if (isset($options['retry-auth-failure'])) { + $this->retryAuthFailure = (bool) $options['retry-auth-failure']; + + unset($options['retry-auth-failure']); + } + if ($this->io->isDebug()) { $this->io->writeError((substr($fileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $fileUrl); } + if (isset($options['github-token'])) { $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['github-token']; unset($options['github-token']); } + + if (isset($options['gitlab-token'])) { + $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['gitlab-token']; + unset($options['gitlab-token']); + } + if (isset($options['http'])) { $options['http']['ignore_errors'] = true; } + if ($this->degradedMode && substr($fileUrl, 0, 21) === 'http://packagist.org/') { // access packagist using the resolved IPv4 instead of the hostname to force IPv4 protocol $fileUrl = 'http://' . gethostbyname('packagist.org') . substr($fileUrl, 20); } + $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet'))); if ($this->progress) { @@ -396,6 +412,14 @@ class RemoteFilesystem ) { throw new TransportException('Could not authenticate against '.$this->originUrl, 401); } + } elseif ($this->config && in_array($this->originUrl, $this->config->get('gitlab-domains'), true)) { + $message = "\n".'Could not fetch '.$this->fileUrl.', enter your ' . $this->originUrl . ' credentials ' .($httpStatus === 401 ? 'to access private repos' : 'to go over the API rate limit'); + $gitLabUtil = new GitLab($this->io, $this->config, null); + if (!$gitLabUtil->authorizeOAuth($this->originUrl) + && (!$this->io->isInteractive() || !$gitLabUtil->authorizeOAuthInteractively($this->scheme, $this->originUrl, $message)) + ) { + throw new TransportException('Could not authenticate against '.$this->originUrl, 401); + } } else { // 404s are only handled for github if ($httpStatus === 404) { @@ -463,6 +487,10 @@ class RemoteFilesystem $auth = $this->io->getAuthentication($originUrl); if ('github.com' === $originUrl && 'x-oauth-basic' === $auth['password']) { $options['github-token'] = $auth['username']; + } elseif ($this->config && in_array($originUrl, $this->config->get('gitlab-domains'), true)) { + if($auth['password'] === 'oauth2') { + $headers[] = 'Authorization: Bearer '.$auth['username']; + } } else { $authStr = base64_encode($auth['username'] . ':' . $auth['password']); $headers[] = 'Authorization: Basic '.$authStr; diff --git a/tests/Composer/Test/Repository/Vcs/GitLabDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitLabDriverTest.php new file mode 100644 index 000000000..d5d6ed832 --- /dev/null +++ b/tests/Composer/Test/Repository/Vcs/GitLabDriverTest.php @@ -0,0 +1,220 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Repository\Vcs; + +use Composer\Repository\Vcs\GitLabDriver; +use Composer\Config; + +/** + * @author Jérôme Tamarelle + */ +class GitLabDriverTest extends \PHPUnit_Framework_TestCase +{ + public function setUp() + { + $this->config = new Config(); + $this->config->merge(array( + 'config' => array( + 'home' => sys_get_temp_dir().'/composer-test', + ), + )); + + $this->io = $this->prophesize('Composer\IO\IOInterface'); + + $this->process = $this->prophesize('Composer\Util\ProcessExecutor'); + + $this->remoteFilesystem = $this->prophesize('Composer\Util\RemoteFilesystem'); + } + + public function getInitializeUrls() + { + return array( + array('https://gitlab.com/mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'), + array('http://gitlab.com/mygroup/myproject', 'http://gitlab.com/api/v3/projects/mygroup%2Fmyproject'), + array('git@gitlab.com:mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'), + ); + } + + /** + * @dataProvider getInitializeUrls + */ + public function testInitialize($url, $apiUrl) + { + // @link http://doc.gitlab.com/ce/api/projects.html#get-single-project + $projectData = <<remoteFilesystem + ->getContents('gitlab.com', $apiUrl, false) + ->willReturn($projectData) + ->shouldBeCalledTimes(1) + ; + + $driver = new GitLabDriver(array('url' => $url), $this->io->reveal(), $this->config, $this->process->reveal(), $this->remoteFilesystem->reveal()); + $driver->initialize(); + + $this->assertEquals($apiUrl, $driver->getApiUrl(), 'API URL is derived from the repository URL'); + $this->assertEquals('mymaster', $driver->getRootIdentifier(), 'Root identifier is the default branch in GitLab'); + $this->assertEquals('git@gitlab.com:mygroup/myproject.git', $driver->getRepositoryUrl(), 'The repository URL is the SSH one by default'); + $this->assertEquals('https://gitlab.com/mygroup/myproject', $driver->getUrl()); + + return $driver; + } + + public function testGetDist() + { + $driver = $this->testInitialize('https://gitlab.com/mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'); + + $reference = 'c3ebdbf9cceddb82cd2089aaef8c7b992e536363'; + $expected = array( + 'type' => 'zip', + 'url' => 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject/repository/archive.zip?sha='.$reference, + 'reference' => $reference, + 'shasum' => '', + ); + + $this->assertEquals($expected, $driver->getDist($reference)); + } + + public function testGetSource() + { + $driver = $this->testInitialize('https://gitlab.com/mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'); + + $reference = 'c3ebdbf9cceddb82cd2089aaef8c7b992e536363'; + $expected = array( + 'type' => 'git', + 'url' => 'git@gitlab.com:mygroup/myproject.git', + 'reference' => $reference, + ); + + $this->assertEquals($expected, $driver->getSource($reference)); + } + + public function testGetTags() + { + $driver = $this->testInitialize('https://gitlab.com/mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'); + + $apiUrl = 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject/repository/tags'; + + // @link http://doc.gitlab.com/ce/api/repositories.html#list-project-repository-tags + $tagData = <<remoteFilesystem + ->getContents('gitlab.com', $apiUrl, false) + ->willReturn($tagData) + ->shouldBeCalledTimes(1) + ; + $driver->setRemoteFilesystem($this->remoteFilesystem->reveal()); + + $expected = array( + 'v1.0.0' => '092ed2c762bbae331e3f51d4a17f67310bf99a81', + 'v2.0.0' => '8e8f60b3ec86d63733db3bd6371117a758027ec6', + ); + + $this->assertEquals($expected, $driver->getTags()); + $this->assertEquals($expected, $driver->getTags(), 'Tags are cached'); + } + + public function testGetBranches() + { + $driver = $this->testInitialize('https://gitlab.com/mygroup/myproject', 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject'); + + $apiUrl = 'https://gitlab.com/api/v3/projects/mygroup%2Fmyproject/repository/branches'; + + // @link http://doc.gitlab.com/ce/api/repositories.html#list-project-repository-branches + $branchData = <<remoteFilesystem + ->getContents('gitlab.com', $apiUrl, false) + ->willReturn($branchData) + ->shouldBeCalledTimes(1) + ; + $driver->setRemoteFilesystem($this->remoteFilesystem->reveal()); + + $expected = array( + 'mymaster' => '97eda36b5c1dd953a3792865c222d4e85e5f302e', + 'staging' => '502cffe49f136443f2059803f2e7192d1ac066cd', + ); + + $this->assertEquals($expected, $driver->getBranches()); + $this->assertEquals($expected, $driver->getBranches(), 'Branches are cached'); + } + + /** + * @dataProvider dataForTestSupports + */ + public function testSupports($url, $expected) + { + $this->assertSame($expected, GitLabDriver::supports($this->io->reveal(), $this->config, $url)); + } + + public function dataForTestSupports() + { + return array( + array('http://gitlab.com/foo/bar', true), + array('http://gitlab.com/foo/bar/', true), + array('http://gitlab.com/foo/bar.git', true), + array('http://gitlab.com/foo/bar.baz.git', true), + array('https://gitlab.com/foo/bar', extension_loaded('openssl')), // Platform requirement + array('git@gitlab.com:foo/bar.git', extension_loaded('openssl')), + array('git@example.com:foo/bar.git', false), + array('http://example.com/foo/bar', false), + ); + } +} diff --git a/tests/Composer/Test/Util/GitLabTest.php b/tests/Composer/Test/Util/GitLabTest.php new file mode 100644 index 000000000..2c5e62527 --- /dev/null +++ b/tests/Composer/Test/Util/GitLabTest.php @@ -0,0 +1,159 @@ + +* Jordi Boggiano +* +* For the full copyright and license information, please view the LICENSE +* file that was distributed with this source code. +*/ + +namespace Composer\Test\Util; + +use Composer\Downloader\TransportException; +use Composer\Util\GitLab; + +/** + * @author Jérôme Tamarelle + */ +class GitLabTest extends \PHPUnit_Framework_TestCase +{ + private $username = 'username'; + private $password = 'password'; + private $authcode = 'authcode'; + private $message = 'mymessage'; + private $origin = 'gitlab.com'; + private $token = 'gitlabtoken'; + + public function testUsernamePasswordAuthenticationFlow() + { + $io = $this->getIOMock(); + $io + ->expects($this->at(0)) + ->method('writeError') + ->with($this->message) + ; + $io + ->expects($this->once()) + ->method('ask') + ->with('Username: ') + ->willReturn($this->username) + ; + $io + ->expects($this->once()) + ->method('askAndHideAnswer') + ->with('Password: ') + ->willReturn($this->password) + ; + + $rfs = $this->getRemoteFilesystemMock(); + $rfs + ->expects($this->once()) + ->method('getContents') + ->with( + $this->equalTo($this->origin), + $this->equalTo(sprintf('http://%s/oauth/token', $this->origin)), + $this->isFalse(), + $this->anything() + ) + ->willReturn(sprintf('{"access_token": "%s", "token_type": "bearer", "expires_in": 7200}', $this->token)) + ; + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(2)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + + $gitLab = new GitLab($io, $config, null, $rfs); + + $this->assertTrue($gitLab->authorizeOAuthInteractively('http', $this->origin, $this->message)); + } + + /** + * @expectedException \RuntimeException + * @expectedExceptionMessage Invalid GitLab credentials 5 times in a row, aborting. + */ + public function testUsernamePasswordFailure() + { + $io = $this->getIOMock(); + $io + ->expects($this->exactly(5)) + ->method('ask') + ->with('Username: ') + ->willReturn($this->username) + ; + $io + ->expects($this->exactly(5)) + ->method('askAndHideAnswer') + ->with('Password: ') + ->willReturn($this->password) + ; + + $rfs = $this->getRemoteFilesystemMock(); + $rfs + ->expects($this->exactly(5)) + ->method('getContents') + ->will($this->throwException(new TransportException('', 401))) + ; + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(1)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + + $gitLab = new GitLab($io, $config, null, $rfs); + + $gitLab->authorizeOAuthInteractively('https', $this->origin); + } + + private function getIOMock() + { + $io = $this + ->getMockBuilder('Composer\IO\ConsoleIO') + ->disableOriginalConstructor() + ->getMock() + ; + + return $io; + } + + private function getConfigMock() + { + $config = $this->getMock('Composer\Config'); + + return $config; + } + + private function getRemoteFilesystemMock() + { + $rfs = $this + ->getMockBuilder('Composer\Util\RemoteFilesystem') + ->disableOriginalConstructor() + ->getMock() + ; + + return $rfs; + } + + private function getAuthJsonMock() + { + $authjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $authjson + ->expects($this->atLeastOnce()) + ->method('getName') + ->willReturn('auth.json') + ; + + return $authjson; + } +}