* 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\IO\IOInterface; use Composer\Test\TestCase; use Composer\Util\AuthHelper; use Composer\Util\Bitbucket; /** * @author Michael Chekin */ class AuthHelperTest extends TestCase { /** @var \Composer\IO\IOInterface&\PHPUnit\Framework\MockObject\MockObject */ private $io; /** @var \Composer\Config&\PHPUnit\Framework\MockObject\MockObject */ private $config; /** @var AuthHelper */ private $authHelper; protected function setUp(): void { $this->io = $this ->getMockBuilder('Composer\IO\IOInterface') ->disableOriginalConstructor() ->getMock(); $this->config = $this->getMockBuilder('Composer\Config')->getMock(); $this->authHelper = new AuthHelper($this->io, $this->config); } public function testAddAuthenticationHeaderWithoutAuthCredentials(): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'http://example.org'; $url = 'file://' . __FILE__; $this->io->expects($this->once()) ->method('hasAuthentication') ->with($origin) ->willReturn(false); self::assertSame( $headers, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public function testAddAuthenticationHeaderWithBearerPassword(): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'http://example.org'; $url = 'file://' . __FILE__; $auth = [ 'username' => 'my_username', 'password' => 'bearer', ]; $this->expectsAuthentication($origin, $auth); $expectedHeaders = array_merge($headers, ['Authorization: Bearer ' . $auth['username']]); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public function testAddAuthenticationHeaderWithGithubToken(): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'github.com'; $url = 'https://api.github.com/'; $auth = [ 'username' => 'my_username', 'password' => 'x-oauth-basic', ]; $this->expectsAuthentication($origin, $auth); $this->io->expects($this->once()) ->method('writeError') ->with('Using GitHub token authentication', true, IOInterface::DEBUG); $expectedHeaders = array_merge($headers, ['Authorization: token ' . $auth['username']]); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public function testAddAuthenticationHeaderWithGitlabOathToken(): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'gitlab.com'; $url = 'https://api.gitlab.com/'; $auth = [ 'username' => 'my_username', 'password' => 'oauth2', ]; $this->expectsAuthentication($origin, $auth); $this->config->expects($this->once()) ->method('get') ->with('gitlab-domains') ->willReturn([$origin]); $this->io->expects($this->once()) ->method('writeError') ->with('Using GitLab OAuth token authentication', true, IOInterface::DEBUG); $expectedHeaders = array_merge($headers, ['Authorization: Bearer ' . $auth['username']]); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public static function gitlabPrivateTokenProvider(): array { return [ ['private-token'], ['gitlab-ci-token'], ]; } /** * @dataProvider gitlabPrivateTokenProvider */ public function testAddAuthenticationHeaderWithGitlabPrivateToken(string $password): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'gitlab.com'; $url = 'https://api.gitlab.com/'; $auth = [ 'username' => 'my_username', 'password' => $password, ]; $this->expectsAuthentication($origin, $auth); $this->config->expects($this->once()) ->method('get') ->with('gitlab-domains') ->willReturn([$origin]); $this->io->expects($this->once()) ->method('writeError') ->with('Using GitLab private token authentication', true, IOInterface::DEBUG); $expectedHeaders = array_merge($headers, ['PRIVATE-TOKEN: ' . $auth['username']]); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public function testAddAuthenticationHeaderWithBitbucketOathToken(): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'bitbucket.org'; $url = 'https://bitbucket.org/site/oauth2/authorize'; $auth = [ 'username' => 'x-token-auth', 'password' => 'my_password', ]; $this->expectsAuthentication($origin, $auth); $this->config->expects($this->once()) ->method('get') ->with('gitlab-domains') ->willReturn([]); $this->io->expects($this->once()) ->method('writeError') ->with('Using Bitbucket OAuth token authentication', true, IOInterface::DEBUG); $expectedHeaders = array_merge($headers, ['Authorization: Bearer ' . $auth['password']]); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public static function bitbucketPublicUrlProvider(): array { return [ ['https://bitbucket.org/user/repo/downloads/whatever'], ['https://bbuseruploads.s3.amazonaws.com/9421ee72-638e-43a9-82ea-39cfaae2bfaa/downloads/b87c59d9-54f3-4922-b711-d89059ec3bcf'], ]; } /** * @dataProvider bitbucketPublicUrlProvider */ public function testAddAuthenticationHeaderWithBitbucketPublicUrl(string $url): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $origin = 'bitbucket.org'; $auth = [ 'username' => 'x-token-auth', 'password' => 'my_password', ]; $this->expectsAuthentication($origin, $auth); $this->config->expects($this->once()) ->method('get') ->with('gitlab-domains') ->willReturn([]); self::assertSame( $headers, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } public static function basicHttpAuthenticationProvider(): array { return [ [ Bitbucket::OAUTH2_ACCESS_TOKEN_URL, 'bitbucket.org', [ 'username' => 'x-token-auth', 'password' => 'my_password', ], ], [ 'https://some-api.url.com', 'some-api.url.com', [ 'username' => 'my_username', 'password' => 'my_password', ], ], [ 'https://gitlab.com', 'gitlab.com', [ 'username' => 'my_username', 'password' => 'my_password', ], ], ]; } /** * @dataProvider basicHttpAuthenticationProvider * * @param array $auth * * @phpstan-param array{username: string|null, password: string|null} $auth */ public function testAddAuthenticationHeaderWithBasicHttpAuthentication(string $url, string $origin, array $auth): void { $headers = [ 'Accept-Encoding: gzip', 'Connection: close', ]; $this->expectsAuthentication($origin, $auth); $this->config->expects($this->once()) ->method('get') ->with('gitlab-domains') ->willReturn([$origin]); $this->io->expects($this->once()) ->method('writeError') ->with( 'Using HTTP basic authentication with username "' . $auth['username'] . '"', true, IOInterface::DEBUG ); $expectedHeaders = array_merge( $headers, ['Authorization: Basic ' . base64_encode($auth['username'] . ':' . $auth['password'])] ); self::assertSame( $expectedHeaders, $this->authHelper->addAuthenticationHeader($headers, $origin, $url) ); } /** * @dataProvider bitbucketPublicUrlProvider */ public function testIsPublicBitBucketDownloadWithBitbucketPublicUrl(string $url): void { self::assertTrue($this->authHelper->isPublicBitBucketDownload($url)); } public function testIsPublicBitBucketDownloadWithNonBitbucketPublicUrl(): void { self::assertFalse( $this->authHelper->isPublicBitBucketDownload( 'https://bitbucket.org/site/oauth2/authorize' ) ); } public function testStoreAuthAutomatically(): void { $origin = 'github.com'; $storeAuth = true; $auth = [ 'username' => 'my_username', 'password' => 'my_password', ]; /** @var \Composer\Config\ConfigSourceInterface&\PHPUnit\Framework\MockObject\MockObject $configSource */ $configSource = $this ->getMockBuilder('Composer\Config\ConfigSourceInterface') ->disableOriginalConstructor() ->getMock(); $this->config->expects($this->once()) ->method('getAuthConfigSource') ->willReturn($configSource); $this->io->expects($this->once()) ->method('getAuthentication') ->with($origin) ->willReturn($auth); $configSource->expects($this->once()) ->method('addConfigSetting') ->with('http-basic.'.$origin, $auth); $this->authHelper->storeAuth($origin, $storeAuth); } public function testStoreAuthWithPromptYesAnswer(): void { $origin = 'github.com'; $storeAuth = 'prompt'; $auth = [ 'username' => 'my_username', 'password' => 'my_password', ]; $answer = 'y'; $configSourceName = 'https://api.gitlab.com/source'; /** @var \Composer\Config\ConfigSourceInterface&\PHPUnit\Framework\MockObject\MockObject $configSource */ $configSource = $this ->getMockBuilder('Composer\Config\ConfigSourceInterface') ->disableOriginalConstructor() ->getMock(); $this->config->expects($this->once()) ->method('getAuthConfigSource') ->willReturn($configSource); $configSource->expects($this->once()) ->method('getName') ->willReturn($configSourceName); $this->io->expects($this->once()) ->method('askAndValidate') ->with( 'Do you want to store credentials for '.$origin.' in '.$configSourceName.' ? [Yn] ', $this->anything(), null, 'y' ) ->willReturnCallback(static function ($question, $validator, $attempts, $default) use ($answer): string { $validator($answer); return $answer; }); $this->io->expects($this->once()) ->method('getAuthentication') ->with($origin) ->willReturn($auth); $configSource->expects($this->once()) ->method('addConfigSetting') ->with('http-basic.'.$origin, $auth); $this->authHelper->storeAuth($origin, $storeAuth); } public function testStoreAuthWithPromptNoAnswer(): void { $origin = 'github.com'; $storeAuth = 'prompt'; $answer = 'n'; $configSourceName = 'https://api.gitlab.com/source'; /** @var \Composer\Config\ConfigSourceInterface&\PHPUnit\Framework\MockObject\MockObject $configSource */ $configSource = $this ->getMockBuilder('Composer\Config\ConfigSourceInterface') ->disableOriginalConstructor() ->getMock(); $this->config->expects($this->once()) ->method('getAuthConfigSource') ->willReturn($configSource); $configSource->expects($this->once()) ->method('getName') ->willReturn($configSourceName); $this->io->expects($this->once()) ->method('askAndValidate') ->with( 'Do you want to store credentials for '.$origin.' in '.$configSourceName.' ? [Yn] ', $this->anything(), null, 'y' ) ->willReturnCallback(static function ($question, $validator, $attempts, $default) use ($answer): string { $validator($answer); return $answer; }); $this->authHelper->storeAuth($origin, $storeAuth); } public function testStoreAuthWithPromptInvalidAnswer(): void { self::expectException('RuntimeException'); $origin = 'github.com'; $storeAuth = 'prompt'; $answer = 'invalid'; $configSourceName = 'https://api.gitlab.com/source'; /** @var \Composer\Config\ConfigSourceInterface&\PHPUnit\Framework\MockObject\MockObject $configSource */ $configSource = $this ->getMockBuilder('Composer\Config\ConfigSourceInterface') ->disableOriginalConstructor() ->getMock(); $this->config->expects($this->once()) ->method('getAuthConfigSource') ->willReturn($configSource); $configSource->expects($this->once()) ->method('getName') ->willReturn($configSourceName); $this->io->expects($this->once()) ->method('askAndValidate') ->with( 'Do you want to store credentials for '.$origin.' in '.$configSourceName.' ? [Yn] ', $this->anything(), null, 'y' ) ->willReturnCallback(static function ($question, $validator, $attempts, $default) use ($answer): string { $validator($answer); return $answer; }); $this->authHelper->storeAuth($origin, $storeAuth); } public function testPromptAuthIfNeededGitLabNoAuthChange(): void { self::expectException('Composer\Downloader\TransportException'); $origin = 'gitlab.com'; $this->io ->method('hasAuthentication') ->with($origin) ->willReturn(true); $this->io ->method('getAuthentication') ->with($origin) ->willReturn([ 'username' => 'gitlab-user', 'password' => 'gitlab-password', ]); $this->io ->expects($this->once()) ->method('setAuthentication') ->with('gitlab.com', 'gitlab-user', 'gitlab-password'); $this->config ->method('get') ->willReturnMap([ ['github-domains', 0, []], ['gitlab-domains', 0, ['gitlab.com']], ['gitlab-token', 0, ['gitlab.com' => ['username' => 'gitlab-user', 'token' => 'gitlab-password']]], ]); $this->authHelper->promptAuthIfNeeded('https://gitlab.com/acme/archive.zip', $origin, 404, 'GitLab requires authentication and it was not provided'); } public function testPromptAuthIfNeededMultipleBitbucketDownloads(): void { $origin = 'bitbucket.org'; $expectedResult = [ 'retry' => true, 'storeAuth' => false, ]; $authConfig = [ 'bitbucket.org' => [ 'access-token' => 'bitbucket_access_token', 'access-token-expiration' => time() + 1800, ] ]; $this->config ->method('get') ->willReturnMap([ ['github-domains', 0, []], ['gitlab-domains', 0, []], ['bitbucket-oauth', 0, $authConfig], ['github-domains', 0, []], ['gitlab-domains', 0, []], ]); $this->io ->expects($this->exactly(2)) ->method('hasAuthentication') ->with($origin) ->willReturn(true); $getAuthenticationReturnValues = [ ['username' => 'bitbucket_client_id', 'password' => 'bitbucket_client_secret'], ['username' => 'x-token-auth', 'password' => 'bitbucket_access_token'], ]; $this->io ->expects($this->exactly(2)) ->method('getAuthentication') ->willReturnCallback( function ($repositoryName) use (&$getAuthenticationReturnValues) { return array_shift($getAuthenticationReturnValues); } ); $this->io ->expects($this->once()) ->method('setAuthentication') ->with($origin, 'x-token-auth', 'bitbucket_access_token'); $result1 = $this->authHelper->promptAuthIfNeeded('https://bitbucket.org/workspace/repo1/get/hash1.zip', $origin, 401, 'HTTP/2 401 '); $result2 = $this->authHelper->promptAuthIfNeeded('https://bitbucket.org/workspace/repo2/get/hash2.zip', $origin, 401, 'HTTP/2 401 '); self::assertSame( $expectedResult, $result1 ); self::assertSame( $expectedResult, $result2 ); } /** * @param array $auth * * @phpstan-param array{username: string|null, password: string|null} $auth */ private function expectsAuthentication(string $origin, array $auth): void { $this->io->expects($this->once()) ->method('hasAuthentication') ->with($origin) ->willReturn(true); $this->io->expects($this->once()) ->method('getAuthentication') ->with($origin) ->willReturn($auth); } }