Browse Source

Merge remote-tracking branch 'stefangr/all_bitbucket_communication_through_oauth'

Jordi Boggiano 9 years ago
parent
commit
dbc7629bb2

+ 142 - 10
src/Composer/Repository/Vcs/GitBitbucketDriver.php

@@ -14,14 +14,19 @@ namespace Composer\Repository\Vcs;
 
 use Composer\Cache;
 use Composer\Config;
+use Composer\Downloader\TransportException;
 use Composer\Json\JsonFile;
 use Composer\IO\IOInterface;
+use Composer\Util\Bitbucket;
 
 /**
  * @author Per Bernhardt <plb@webfactory.de>
  */
 class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
 {
+    /**
+     * @var Cache
+     */
     protected $cache;
     protected $owner;
     protected $repository;
@@ -29,6 +34,11 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
     protected $branches;
     protected $rootIdentifier;
     protected $infoCache = array();
+    private $hasIssues;
+    /**
+     * @var GitDriver
+     */
+    private $gitDriver;
 
     /**
      * {@inheritDoc}
@@ -47,9 +57,14 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getRootIdentifier()
     {
+        if ($this->gitDriver) {
+            return $this->gitDriver->getRootIdentifier();
+        }
+
         if (null === $this->rootIdentifier) {
             $resource = $this->getScheme() . '://api.bitbucket.org/1.0/repositories/'.$this->owner.'/'.$this->repository;
-            $repoData = JsonFile::parseJson($this->getContents($resource), $resource);
+            $repoData = JsonFile::parseJson($this->getContentsWithOAuthCredentials($resource, true), $resource);
+            $this->hasIssues = !empty($repoData['has_issues']);
             $this->rootIdentifier = !empty($repoData['main_branch']) ? $repoData['main_branch'] : 'master';
         }
 
@@ -61,7 +76,11 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getUrl()
     {
-        return $this->url;
+        if ($this->gitDriver) {
+            return $this->gitDriver->getUrl();
+        }
+
+        return 'https://' . $this->originUrl . '/'.$this->owner.'/'.$this->repository.'.git';
     }
 
     /**
@@ -69,6 +88,10 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getSource($identifier)
     {
+        if ($this->gitDriver) {
+            return $this->gitDriver->getSource($identifier);
+        }
+
         return array('type' => 'git', 'url' => $this->getUrl(), 'reference' => $identifier);
     }
 
@@ -87,24 +110,53 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getComposerInformation($identifier)
     {
+        if ($this->gitDriver) {
+            return $this->gitDriver->getComposerInformation($identifier);
+        }
+
         if (preg_match('{[a-f0-9]{40}}i', $identifier) && $res = $this->cache->read($identifier)) {
             $this->infoCache[$identifier] = JsonFile::parseJson($res);
         }
 
         if (!isset($this->infoCache[$identifier])) {
-            $resource = $this->getScheme() . '://bitbucket.org/'.$this->owner.'/'.$this->repository.'/raw/'.$identifier.'/composer.json';
-            $composer = $this->getContents($resource);
-            if (!$composer) {
-                return;
+            $resource = $this->getScheme() . '://api.bitbucket.org/1.0/repositories/'.$this->owner.'/'.$this->repository.'/src/'.$identifier.'/composer.json';
+            $file = JsonFile::parseJson($this->getContentsWithOAuthCredentials($resource), $resource);
+            if (!is_array($file) || ! array_key_exists('data', $file)) {
+                return array();
             }
 
-            $composer = JsonFile::parseJson($composer, $resource);
+            $composer = JsonFile::parseJson($file['data'], $resource);
 
             if (empty($composer['time'])) {
                 $resource = $this->getScheme() . '://api.bitbucket.org/1.0/repositories/'.$this->owner.'/'.$this->repository.'/changesets/'.$identifier;
-                $changeset = JsonFile::parseJson($this->getContents($resource), $resource);
+                $changeset = JsonFile::parseJson($this->getContentsWithOAuthCredentials($resource), $resource);
                 $composer['time'] = $changeset['timestamp'];
             }
+            if (!isset($composer['support']['source'])) {
+                $label = array_search($identifier, $this->getTags()) ?: array_search($identifier, $this->getBranches()) ?: $identifier;
+
+                if (array_key_exists($label, $tags = $this->getTags())) {
+                    $hash = $tags[$label];
+                } elseif (array_key_exists($label, $branches = $this->getBranches())) {
+                    $hash = $branches[$label];
+                }
+
+                if (! isset($hash)) {
+                    $composer['support']['source'] = sprintf('https://%s/%s/%s/src', $this->originUrl, $this->owner, $this->repository);
+                } else {
+                    $composer['support']['source'] = sprintf(
+                        'https://%s/%s/%s/src/%s/?at=%s',
+                        $this->originUrl,
+                        $this->owner,
+                        $this->repository,
+                        $hash,
+                        $label
+                    );
+                }
+            }
+            if (!isset($composer['support']['issues']) && $this->hasIssues) {
+                $composer['support']['issues'] = sprintf('https://%s/%s/%s/issues', $this->originUrl, $this->owner, $this->repository);
+            }
 
             if (preg_match('{[a-f0-9]{40}}i', $identifier)) {
                 $this->cache->write($identifier, json_encode($composer));
@@ -121,9 +173,13 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getTags()
     {
+        if ($this->gitDriver) {
+            return $this->gitDriver->getTags();
+        }
+
         if (null === $this->tags) {
             $resource = $this->getScheme() . '://api.bitbucket.org/1.0/repositories/'.$this->owner.'/'.$this->repository.'/tags';
-            $tagsData = JsonFile::parseJson($this->getContents($resource), $resource);
+            $tagsData = JsonFile::parseJson($this->getContentsWithOAuthCredentials($resource), $resource);
             $this->tags = array();
             foreach ($tagsData as $tag => $data) {
                 $this->tags[$tag] = $data['raw_node'];
@@ -138,9 +194,13 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
      */
     public function getBranches()
     {
+        if ($this->gitDriver) {
+            return $this->gitDriver->getBranches();
+        }
+
         if (null === $this->branches) {
             $resource =  $this->getScheme() . '://api.bitbucket.org/1.0/repositories/'.$this->owner.'/'.$this->repository.'/branches';
-            $branchData = JsonFile::parseJson($this->getContents($resource), $resource);
+            $branchData = JsonFile::parseJson($this->getContentsWithOAuthCredentials($resource), $resource);
             $this->branches = array();
             foreach ($branchData as $branch => $data) {
                 $this->branches[$branch] = $data['raw_node'];
@@ -167,4 +227,76 @@ class GitBitbucketDriver extends VcsDriver implements VcsDriverInterface
 
         return true;
     }
+
+    protected function attemptCloneFallback()
+    {
+        try {
+            $this->setupGitDriver($this->generateSshUrl());
+
+            return;
+        } catch (\RuntimeException $e) {
+            $this->gitDriver = null;
+
+            $this->io->writeError('<error>Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your Bitbucket OAuth consumer credentials</error>');
+            throw $e;
+        }
+    }
+
+    /**
+     * Generate an SSH URL
+     *
+     * @return string
+     */
+    private function generateSshUrl()
+    {
+        return 'git@' . $this->originUrl . ':' . $this->owner.'/'.$this->repository.'.git';
+    }
+
+    /**
+     * Get the remote content.
+     *
+     * @param string $url The URL of content
+     * @param bool $fetchingRepoData
+     *
+     * @return mixed The result
+     */
+    protected function getContentsWithOAuthCredentials($url, $fetchingRepoData = false)
+    {
+        try {
+            return parent::getContents($url);
+        } catch (TransportException $e) {
+            $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process, $this->remoteFilesystem);
+
+            switch ($e->getCode()) {
+                case 403:
+                    if (!$this->io->hasAuthentication($this->originUrl) && $bitbucketUtil->authorizeOAuth($this->originUrl)) {
+                        return parent::getContents($url);
+                    }
+
+                    if (!$this->io->isInteractive() && $fetchingRepoData) {
+                        return $this->attemptCloneFallback();
+                    }
+
+                    throw $e;
+
+                default:
+                    throw $e;
+            }
+        }
+    }
+
+    /**
+     * @param string $url
+     */
+    private function setupGitDriver($url)
+    {
+        $this->gitDriver = new GitDriver(
+            array('url' => $url),
+            $this->io,
+            $this->config,
+            $this->process,
+            $this->remoteFilesystem
+        );
+        $this->gitDriver->initialize();
+    }
 }

+ 12 - 5
src/Composer/Util/Bitbucket.php

@@ -28,6 +28,8 @@ class Bitbucket
     private $remoteFilesystem;
     private $token = array();
 
+    const OAUTH2_ACCESS_TOKEN_URL = 'https://bitbucket.org/site/oauth2/access_token';
+
     /**
      * Constructor.
      *
@@ -81,9 +83,7 @@ class Bitbucket
     private function requestAccessToken($originUrl)
     {
         try {
-            $apiUrl = 'https://bitbucket.org/site/oauth2/access_token';
-
-            $json = $this->remoteFilesystem->getContents($originUrl, $apiUrl, false, array(
+            $json = $this->remoteFilesystem->getContents($originUrl, self::OAUTH2_ACCESS_TOKEN_URL, false, array(
                 'retry-auth-failure' => false,
                 'http' => array(
                     'method' => 'POST',
@@ -93,8 +93,15 @@ class Bitbucket
 
             $this->token = json_decode($json, true);
         } catch (TransportException $e) {
-            if (in_array($e->getCode(), array(403, 401))) {
-                $this->io->writeError('<error>Invalid consumer provided.</error>');
+            if ($e->getCode() === 400) {
+                $this->io->writeError('<error>Invalid OAuth consumer provided.</error>');
+                $this->io->writeError('This can have two reasons:');
+                $this->io->writeError('1. You are authenticating with a bitbucket username/password combination');
+                $this->io->writeError('2. You are using an OAuth consumer, but didn\'t configure a (dummy) callback url');
+
+                return false;
+            } elseif (in_array($e->getCode(), array(403, 401))) {
+                $this->io->writeError('<error>Invalid OAuth consumer provided.</error>');
                 $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
 
                 return false;

+ 3 - 1
src/Composer/Util/Git.php

@@ -129,7 +129,9 @@ class Git
                     //We already have an access_token from a previous request.
                     if ($auth['username'] !== 'x-token-auth') {
                         $token = $bitbucketUtil->requestToken($match[1], $auth['username'], $auth['password']);
-                        $this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']);
+                        if (! empty($token)) {
+                            $this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']);
+                        }
                     }
                 }
 

+ 2 - 2
src/Composer/Util/ProcessExecutor.php

@@ -45,11 +45,11 @@ class ProcessExecutor
     {
         if ($this->io && $this->io->isDebug()) {
             $safeCommand = preg_replace_callback('{(://)(?P<user>[^:/\s]+):(?P<password>[^@\s/]+)}i', function ($m) {
-                if (preg_match('{^[a-f0-9]{12,}$}', $m[1])) {
+                if (preg_match('{^[a-f0-9]{12,}$}', $m[2])) {
                     return '://***:***';
                 }
 
-                return '://'.$m[1].':***';
+                return '://'.$m[2].':***';
             }, $command);
             $this->io->writeError('Executing command ('.($cwd ?: 'CWD').'): '.$safeCommand);
         }

+ 28 - 0
src/Composer/Util/RemoteFilesystem.php

@@ -245,6 +245,11 @@ class RemoteFilesystem
             unset($options['gitlab-token']);
         }
 
+        if (isset($options['bitbucket-token'])) {
+            $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['bitbucket-token'];
+            unset($options['bitbucket-token']);
+        }
+
         if (isset($options['http'])) {
             $options['http']['ignore_errors'] = true;
         }
@@ -569,6 +574,25 @@ class RemoteFilesystem
             ) {
                 throw new TransportException('Could not authenticate against '.$this->originUrl, 401);
             }
+        } elseif ($this->config && $this->originUrl === 'bitbucket.org') {
+            if (! $this->io->hasAuthentication($this->originUrl)) {
+                $message = "\n".'Could not fetch ' . $this->fileUrl . ', please create a bitbucket OAuth token to access private repos';
+                $bitBucketUtil = new Bitbucket($this->io, $this->config);
+                if (! $bitBucketUtil->authorizeOAuth($this->originUrl)
+                    && (! $this->io->isInteractive() || !$bitBucketUtil->authorizeOAuthInteractively($this->originUrl, $message))
+                ) {
+                    throw new TransportException('Could not authenticate against ' . $this->originUrl, 401);
+                }
+            } else {
+                $auth = $this->io->getAuthentication($this->originUrl);
+                if ($auth['username'] !== 'x-token-auth') {
+                    $bitbucketUtil = new Bitbucket($this->io, $this->config);
+                    $token = $bitbucketUtil->requestToken($this->originUrl, $auth['username'], $auth['password']);
+                    $this->io->setAuthentication($this->originUrl, 'x-token-auth', $token['access_token']);
+                } else {
+                    throw new TransportException('Could not authenticate against ' . $this->originUrl, 401);
+                }
+            }
         } else {
             // 404s are only handled for github
             if ($httpStatus === 404) {
@@ -671,6 +695,10 @@ class RemoteFilesystem
                 if ($auth['password'] === 'oauth2') {
                     $headers[] = 'Authorization: Bearer '.$auth['username'];
                 }
+            } elseif ('bitbucket.org' === $originUrl
+                && $this->fileUrl !== Bitbucket::OAUTH2_ACCESS_TOKEN_URL && 'x-token-auth' === $auth['username']
+            ) {
+                $options['bitbucket-token'] = $auth['password'];
             } else {
                 $authStr = base64_encode($auth['username'] . ':' . $auth['password']);
                 $headers[] = 'Authorization: Basic '.$authStr;

+ 228 - 0
tests/Composer/Test/Repository/Vcs/GitBitbucketDriverTest.php

@@ -0,0 +1,228 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * 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\TestCase;
+use Composer\Util\Filesystem;
+
+/**
+ * @group bitbucket
+ */
+class GitBitbucketDriverTest extends TestCase
+{
+    /** @type \Composer\IO\IOInterface|\PHPUnit_Framework_MockObject_MockObject */
+    private $io;
+    /** @type \Composer\Config */
+    private $config;
+    /** @type \Composer\Util\RemoteFilesystem|\PHPUnit_Framework_MockObject_MockObject */
+    private $rfs;
+    /** @type string */
+    private $home;
+    /** @type string */
+    private $originUrl = 'bitbucket.org';
+
+    protected function setUp()
+    {
+        $this->io = $this->getMock('Composer\IO\IOInterface');
+
+        $this->home = $this->getUniqueTmpDirectory();
+
+        $this->config = new Config();
+        $this->config->merge(array(
+            'config' => array(
+                'home' => $this->home,
+            ),
+        ));
+
+        $this->rfs = $this->getMockBuilder('Composer\Util\RemoteFilesystem')
+            ->disableOriginalConstructor()
+            ->getMock();
+    }
+
+    public function tearDown()
+    {
+        $fs = new Filesystem;
+        $fs->removeDirectory($this->home);
+    }
+
+    /**
+     * @param array $repoConfig
+     * @return GitBitbucketDriver
+     */
+    private function getDriver(array $repoConfig)
+    {
+        $driver = new GitBitbucketDriver(
+            $repoConfig,
+            $this->io,
+            $this->config,
+            null,
+            $this->rfs
+        );
+
+        $driver->initialize();
+
+        return $driver;
+    }
+
+    public function testGetRootIdentifier()
+    {
+        $driver = $this->getDriver(array('url' => 'https://bitbucket.org/user/repo.git'));
+
+        $this->rfs->expects($this->any())
+            ->method('getContents')
+            ->with(
+                $this->originUrl,
+                'https://api.bitbucket.org/1.0/repositories/user/repo',
+                false
+            )
+            ->willReturn(
+                '{"scm": "git", "has_wiki": false, "last_updated": "2016-05-17T13:20:21.993", "no_forks": true, "forks_count": 0, "created_on": "2015-02-18T16:22:24.688", "owner": "user", "logo": "https://bitbucket.org/user/repo/avatar/32/?ts=1463484021", "email_mailinglist": "", "is_mq": false, "size": 9975494, "read_only": false, "fork_of": null, "mq_of": null, "followers_count": 0, "state": "available", "utc_created_on": "2015-02-18 15:22:24+00:00", "website": "", "description": "", "has_issues": false, "is_fork": false, "slug": "repo", "is_private": true, "name": "repo", "language": "php", "utc_last_updated": "2016-05-17 11:20:21+00:00", "no_public_forks": true, "creator": null, "resource_uri": "/1.0/repositories/user/repo"}'
+            );
+
+        $this->assertEquals(
+            'master',
+            $driver->getRootIdentifier()
+        );
+    }
+
+    public function testGetParams()
+    {
+        $url = 'https://bitbucket.org/user/repo.git';
+        $driver = $this->getDriver(array('url' => $url));
+
+        $this->assertEquals($url, $driver->getUrl());
+
+        $this->assertEquals(
+            array(
+                'type' => 'zip',
+                'url' => 'https://bitbucket.org/user/repo/get/reference.zip',
+                'reference' => 'reference',
+                'shasum' => ''
+            ),
+            $driver->getDist('reference')
+        );
+
+        $this->assertEquals(
+            array('type' => 'git', 'url' => $url, 'reference' => 'reference'),
+            $driver->getSource('reference')
+        );
+    }
+
+    public function testGetComposerInformation()
+    {
+        $driver = $this->getDriver(array('url' => 'https://bitbucket.org/user/repo.git'));
+
+        $this->rfs->expects($this->any())
+            ->method('getContents')
+            ->withConsecutive(
+                array('bitbucket.org', 'https://api.bitbucket.org/1.0/repositories/user/repo/src/master/composer.json', false),
+                array('bitbucket.org', 'https://api.bitbucket.org/1.0/repositories/user/repo/changesets/master', false),
+                array('bitbucket.org', 'https://api.bitbucket.org/1.0/repositories/user/repo/tags', false),
+                array('bitbucket.org', 'https://api.bitbucket.org/1.0/repositories/user/repo/branches', false)
+            )
+            ->willReturnOnConsecutiveCalls(
+                '{"node": "937992d19d72", "path": "composer.json", "data": "{\n  \"name\": \"user/repo\",\n  \"description\": \"test repo\",\n  \"license\": \"GPL\",\n  \"authors\": [\n    {\n      \"name\": \"Name\",\n      \"email\": \"local@domain.tld\"\n    }\n  ],\n  \"require\": {\n    \"creator/package\": \"^1.0\"\n  },\n  \"require-dev\": {\n    \"phpunit/phpunit\": \"~4.8\"\n  }\n}\n", "size": 269}',
+                '{"node": "937992d19d72", "files": [{"type": "modified", "file": "path/to/file"}], "raw_author": "User <local@domain.tld>", "utctimestamp": "2016-05-17 11:19:52+00:00", "author": "user", "timestamp": "2016-05-17 13:19:52", "raw_node": "937992d19d72b5116c3e8c4a04f960e5fa270b22", "parents": ["71e195a33361"], "branch": "master", "message": "Commit message\n", "revision": null, "size": -1}',
+                '{}',
+                '{"master": {"node": "937992d19d72", "files": [{"type": "modified", "file": "path/to/file"}], "raw_author": "User <local@domain.tld>", "utctimestamp": "2016-05-17 11:19:52+00:00", "author": "user", "timestamp": "2016-05-17 13:19:52", "raw_node": "937992d19d72b5116c3e8c4a04f960e5fa270b22", "parents": ["71e195a33361"], "branch": "master", "message": "Commit message\n", "revision": null, "size": -1}}'
+            );
+
+        $this->assertEquals(
+            array(
+                'name' => 'user/repo',
+                'description' => 'test repo',
+                'license' => 'GPL',
+                'authors' => array(
+                    array(
+                        'name' => 'Name',
+                        'email' => 'local@domain.tld'
+                    )
+                ),
+                'require' => array(
+                    'creator/package' => '^1.0'
+                ),
+                'require-dev' => array(
+                    'phpunit/phpunit' => '~4.8'
+                ),
+                'time' => '2016-05-17 13:19:52',
+                'support' => array(
+                    'source' => 'https://bitbucket.org/user/repo/src/937992d19d72b5116c3e8c4a04f960e5fa270b22/?at=master'
+                )
+            ),
+            $driver->getComposerInformation('master')
+        );
+    }
+
+    public function testGetTags()
+    {
+        $driver = $this->getDriver(array('url' => 'https://bitbucket.org/user/repo.git'));
+
+        $this->rfs->expects($this->once())
+            ->method('getContents')
+            ->with(
+                'bitbucket.org',
+                'https://api.bitbucket.org/1.0/repositories/user/repo/tags',
+                false
+            )
+            ->willReturn(
+                '{"1.0.1": {"node": "9b78a3932143", "files": [{"type": "modified", "file": "path/to/file"}], "branches": [], "raw_author": "User <local@domain.tld>", "utctimestamp": "2015-04-16 14:50:40+00:00", "author": "user", "timestamp": "2015-04-16 16:50:40", "raw_node": "9b78a3932143497c519e49b8241083838c8ff8a1", "parents": ["84531c04dbfc", "50c2a4635ad0"], "branch": null, "message": "Commit message\n", "revision": null, "size": -1}, "1.0.0": {"node": "d3393d514318", "files": [{"type": "modified", "file": "path/to/file2"}], "branches": [], "raw_author": "User <local@domain.tld>", "utctimestamp": "2015-04-16 09:31:45+00:00", "author": "user", "timestamp": "2015-04-16 11:31:45", "raw_node": "d3393d514318a9267d2f8ebbf463a9aaa389f8eb", "parents": ["5a29a73cd1a0"], "branch": null, "message": "Commit message\n", "revision": null, "size": -1}}'
+            );
+
+        $this->assertEquals(
+            array(
+                '1.0.1' => '9b78a3932143497c519e49b8241083838c8ff8a1',
+                '1.0.0' => 'd3393d514318a9267d2f8ebbf463a9aaa389f8eb'
+            ),
+            $driver->getTags()
+        );
+    }
+
+    public function testGetBranches()
+    {
+        $driver = $this->getDriver(array('url' => 'https://bitbucket.org/user/repo.git'));
+
+        $this->rfs->expects($this->once())
+            ->method('getContents')
+            ->with(
+                'bitbucket.org',
+                'https://api.bitbucket.org/1.0/repositories/user/repo/branches',
+                false
+            )
+            ->willReturn(
+                '{"master": {"node": "937992d19d72", "files": [{"type": "modified", "file": "path/to/file"}], "raw_author": "User <local@domain.tld>", "utctimestamp": "2016-05-17 11:19:52+00:00", "author": "user", "timestamp": "2016-05-17 13:19:52", "raw_node": "937992d19d72b5116c3e8c4a04f960e5fa270b22", "parents": ["71e195a33361"], "branch": "master", "message": "Commit message\n", "revision": null, "size": -1}}'
+            );
+
+        $this->assertEquals(
+            array(
+                'master' => '937992d19d72b5116c3e8c4a04f960e5fa270b22'
+            ),
+            $driver->getBranches()
+        );
+    }
+
+    public function testSupports()
+    {
+        $this->assertTrue(
+            GitBitbucketDriver::supports($this->io, $this->config, 'https://bitbucket.org/user/repo.git')
+        );
+
+        $this->assertFalse(
+            GitBitbucketDriver::supports($this->io, $this->config, 'git@bitbucket.org:user/repo.git')
+        );
+
+        $this->assertFalse(
+            GitBitbucketDriver::supports($this->io, $this->config, 'https://github.com/user/repo.git')
+        );
+    }
+}

+ 118 - 42
tests/Composer/Test/Util/BitbucketTest.php

@@ -21,30 +21,138 @@ class BitbucketTest extends \PHPUnit_Framework_TestCase
 {
     private $username = 'username';
     private $password = 'password';
-    private $authcode = 'authcode';
+    private $consumer_key = 'consumer_key';
+    private $consumer_secret = 'consumer_secret';
     private $message = 'mymessage';
     private $origin = 'bitbucket.org';
     private $token = 'bitbuckettoken';
 
+    /** @type \Composer\IO\ConsoleIO|\PHPUnit_Framework_MockObject_MockObject */
+    private $io;
+    /** @type \Composer\Util\RemoteFilesystem|\PHPUnit_Framework_MockObject_MockObject */
+    private $rfs;
+    /** @type \Composer\Config|\PHPUnit_Framework_MockObject_MockObject */
+    private $config;
+    /** @type Bitbucket */
+    private $bitbucket;
+
+    protected function setUp()
+    {
+        $this->io = $this
+            ->getMockBuilder('Composer\IO\ConsoleIO')
+            ->disableOriginalConstructor()
+            ->getMock()
+        ;
+
+        $this->rfs = $this
+            ->getMockBuilder('Composer\Util\RemoteFilesystem')
+            ->disableOriginalConstructor()
+            ->getMock()
+        ;
+
+        $this->config = $this->getMock('Composer\Config');
+
+        $this->bitbucket = new Bitbucket($this->io, $this->config, null, $this->rfs);
+    }
+
+    public function testRequestAccessTokenWithValidOAuthConsumer()
+    {
+        $this->io->expects($this->once())
+            ->method('setAuthentication')
+            ->with($this->origin, $this->consumer_key, $this->consumer_secret);
+
+        $this->rfs->expects($this->once())
+            ->method('getContents')
+            ->with(
+                $this->origin,
+                Bitbucket::OAUTH2_ACCESS_TOKEN_URL,
+                false,
+                array(
+                    'retry-auth-failure' => false,
+                    'http' => array(
+                        'method' => 'POST',
+                        'content' => 'grant_type=client_credentials',
+                    )
+                )
+            )
+            ->willReturn(
+                sprintf(
+                    '{"access_token": "%s", "scopes": "repository", "expires_in": 3600, "refresh_token": "refreshtoken", "token_type": "bearer"}',
+                    $this->token
+                )
+            );
+
+        $this->assertEquals(
+            array(
+                'access_token' => $this->token,
+                'scopes' => 'repository',
+                'expires_in' => 3600,
+                'refresh_token' => 'refreshtoken',
+                'token_type' => 'bearer'
+            ),
+            $this->bitbucket->requestToken($this->origin, $this->consumer_key, $this->consumer_secret)
+        );
+    }
+
+    public function testRequestAccessTokenWithUsernameAndPassword()
+    {
+        $this->io->expects($this->once())
+            ->method('setAuthentication')
+            ->with($this->origin, $this->username, $this->password);
+
+        $this->io->expects($this->any())
+            ->method('writeError')
+            ->withConsecutive(
+                array('<error>Invalid OAuth consumer provided.</error>'),
+                array('This can have two reasons:'),
+                array('1. You are authenticating with a bitbucket username/password combination'),
+                array('2. You are using an OAuth consumer, but didn\'t configure a (dummy) callback url')
+            );
+
+        $this->rfs->expects($this->once())
+            ->method('getContents')
+            ->with(
+                $this->origin,
+                Bitbucket::OAUTH2_ACCESS_TOKEN_URL,
+                false,
+                array(
+                    'retry-auth-failure' => false,
+                    'http' => array(
+                        'method' => 'POST',
+                        'content' => 'grant_type=client_credentials',
+                    )
+                )
+            )
+            ->willThrowException(
+                new \Composer\Downloader\TransportException(
+                    sprintf(
+                        'The \'%s\' URL could not be accessed: HTTP/1.1 400 BAD REQUEST',
+                        Bitbucket::OAUTH2_ACCESS_TOKEN_URL
+                    ),
+                    400
+                )
+            );
+
+        $this->assertEquals(array(), $this->bitbucket->requestToken($this->origin, $this->username, $this->password));
+    }
+
     public function testUsernamePasswordAuthenticationFlow()
     {
-        $io = $this->getIOMock();
-        $io
+        $this->io
             ->expects($this->at(0))
             ->method('writeError')
             ->with($this->message)
         ;
 
-        $io->expects($this->exactly(2))
+        $this->io->expects($this->exactly(2))
             ->method('askAndHideAnswer')
             ->withConsecutive(
                 array('Consumer Key (hidden): '),
                 array('Consumer Secret (hidden): ')
             )
-            ->willReturnOnConsecutiveCalls($this->username, $this->password);
+            ->willReturnOnConsecutiveCalls($this->consumer_key, $this->consumer_secret);
 
-        $rfs = $this->getRemoteFilesystemMock();
-        $rfs
+        $this->rfs
             ->expects($this->once())
             ->method('getContents')
             ->with(
@@ -56,50 +164,18 @@ class BitbucketTest extends \PHPUnit_Framework_TestCase
             ->willReturn(sprintf('{}', $this->token))
         ;
 
-        $config = $this->getConfigMock();
-        $config
+        $this->config
             ->expects($this->exactly(2))
             ->method('getAuthConfigSource')
             ->willReturn($this->getAuthJsonMock())
         ;
-        $config
+        $this->config
             ->expects($this->once())
             ->method('getConfigSource')
             ->willReturn($this->getConfJsonMock())
         ;
 
-        $bitbucket = new Bitbucket($io, $config, null, $rfs);
-
-        $this->assertTrue($bitbucket->authorizeOAuthInteractively($this->origin, $this->message));
-    }
-
-    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;
+        $this->assertTrue($this->bitbucket->authorizeOAuthInteractively($this->origin, $this->message));
     }
 
     private function getAuthJsonMock()