diff --git a/README.md b/README.md index d7cc8926..0e2908a8 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ VCS Adapters: | Adapter | Status | |---------|---------| | GitHub | ✅ | -| GitLab | | -| Bitbucket | | +| GitLab | ✅ | +| Bitbucket | ✅ | | Azure DevOps | | `✅ - supported, 🛠 - work in progress` diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index f8816925..51f3201c 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -99,6 +99,56 @@ abstract public function createWebhook(string $owner, string $repositoryName, st */ abstract public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array; + /** + * Whether the provider lets this integration create repositories. Some + * partner APIs reserve creation for user principals. + */ + public function supportsRepositoryCreation(): bool + { + return true; + } + + /** + * Whether the provider exposes an endpoint to delete a repository. + */ + public function supportsRepositoryDeletion(): bool + { + return true; + } + + /** + * Whether the provider can hand out an archive download URL at all, so a + * consumer can arrange its own source packaging before calling + * getRepositoryPresignedUrl() just to catch it throwing. + */ + public function supportsRepositoryArchives(): bool + { + return true; + } + + /** + * Whether images embedded in pull request comments can render on the + * provider. Providers without an image proxy (the way GitHub rewrites + * comment images through its camo CDN) cannot display images hosted on + * the consumer's own host - often private or plain-HTTP - so consumers + * should fall back to text there. + */ + public function supportsCommentImages(): bool + { + return true; + } + + /** + * Whether the provider can host repositories that anonymous clients are + * able to read. Providers that scope every repository to an + * authenticated audience have no public repositories, whatever a + * visibility flag may claim. + */ + public function supportsPublicRepositories(): bool + { + return true; + } + /** * Headers a caller must send with getRepositoryPresignedUrl() to reach a * private repository. diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 3bb9cffb..84cd3686 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -50,6 +50,11 @@ protected function signWebhookPayload(string $payload, string $secret): string return 'sha256=' . hash_hmac('sha256', $payload, $secret); } + protected function anonymousCloneUrl(string $repositoryName): string + { + return 'https://bitbucket.org/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Adapter/ForgejoTest.php b/tests/VCS/Adapter/ForgejoTest.php index 5ee0436e..1e9ce230 100644 --- a/tests/VCS/Adapter/ForgejoTest.php +++ b/tests/VCS/Adapter/ForgejoTest.php @@ -41,6 +41,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_FORGEJO_URL', 'http://forgejo:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupForgejo(): void { $tokenFile = '/forgejo-data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index a31f5851..a29cb45d 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -33,6 +33,11 @@ protected function signWebhookPayload(string $payload, string $secret): string protected static string $eventHeader = 'x-github-event'; protected static string $signatureHeader = 'x-hub-signature-256'; + protected function anonymousCloneUrl(string $repositoryName): string + { + return 'https://github.com/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupAdapter(): void { $privateKey = str_replace('\\n', "\n", System::getEnv('TESTS_GITHUB_PRIVATE_KEY') ?? ''); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 13c4704b..62625b89 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -69,6 +69,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GITLAB_URL', 'http://gitlab:80') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + /** * GitLab owners are carried as "id:path", but it reports the path alone. */ diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 91d735f1..2777cd38 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -59,6 +59,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GITEA_URL', 'http://gitea:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupGitea(): void { $tokenFile = '/data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 10c5a7d1..14bd6c09 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -48,6 +48,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GOGS_URL', 'http://gogs:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupGogs(): void { $tokenFile = '/gogs-data/gogs/token.txt'; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index ab571542..625d29a1 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -204,6 +204,12 @@ abstract protected function pushPayload(string $branch, array $added = [], array */ abstract protected function pullRequestPayload(bool $external = false): string; + /** + * URL an anonymous git client would clone the repository from over HTTP, + * with no credentials embedded. + */ + abstract protected function anonymousCloneUrl(string $repositoryName): string; + protected function setUp(): void { $this->setupAdapter(); @@ -476,6 +482,54 @@ public function testCreatePrivateRepository(): void } } + /** + * Response an anonymous git client gets for the repository: the ref + * advertisement request `git clone` opens with, sent without credentials. + * + * @return array{0: int, 1: string} Status code and body + */ + private function fetchAnonymousRefAdvertisement(string $repositoryName): array + { + $client = new Client(); + $response = $client->fetch( + url: $this->anonymousCloneUrl($repositoryName) . '/info/refs', + method: 'GET', + query: ['service' => 'git-upload-pack'] + ); + + return [$response->getStatusCode(), $response->text()]; + } + + /** + * The visibility flag alone proves nothing about what reaches the + * outside; a public repository has to answer an anonymous git client. + * A private repository has to refuse the same request, or the public + * answer would say nothing beyond the server being up. + */ + public function testPublicRepositoryIsPubliclyAccessible(): void + { + $this->skipUnlessSupported($this->vcsAdapter->supportsPublicRepositories(), 'public repositories'); + + $publicRepository = 'test-public-access-' . \uniqid(); + $privateRepository = 'test-private-access-' . \uniqid(); + + $this->vcsAdapter->createRepository(static::$owner, $publicRepository, false); + $this->vcsAdapter->createRepository(static::$owner, $privateRepository, true); + + try { + $this->assertEventually(function () use ($publicRepository) { + [$status, $body] = $this->fetchAnonymousRefAdvertisement($publicRepository); + $this->assertSame(200, $status, 'An anonymous git client cannot reach the public repository'); + $this->assertStringContainsString('git-upload-pack', $body, 'The anonymous response is not a git ref advertisement'); + }); + + [$status] = $this->fetchAnonymousRefAdvertisement($privateRepository); + $this->assertNotSame(200, $status, 'An anonymous git client can read the private repository'); + } finally { + $this->discardRepositories($publicRepository, $privateRepository); + } + } + public function testGetRepository(): void { $repositoryName = 'test-get-repository-' . \uniqid();