From 86fb9fe1a88d872b2cd949240545ff721395a617 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 09:17:43 +0100 Subject: [PATCH 1/2] test(pat): pin PatValidator::validate with a golden scenario set Assisted-by: ClaudeCode:claude-opus-5-5 --- .../Service/Pat/PatValidatorGoldenTest.php | 170 + .../Pat/fixtures/pat-validate.golden.json | 4479 +++++++++++++++++ 2 files changed, 4649 insertions(+) create mode 100644 tests/unit/Service/Pat/PatValidatorGoldenTest.php create mode 100644 tests/unit/Service/Pat/fixtures/pat-validate.golden.json diff --git a/tests/unit/Service/Pat/PatValidatorGoldenTest.php b/tests/unit/Service/Pat/PatValidatorGoldenTest.php new file mode 100644 index 00000000..551ba454 --- /dev/null +++ b/tests/unit/Service/Pat/PatValidatorGoldenTest.php @@ -0,0 +1,170 @@ + 'ghp_classic123', + 'forge' => 'github', + 'forgejo' => false, + 'allowLocal' => false, + 'probe' => 200, + 'headers' => ['X-OAuth-Scopes' => ['repo'], 'github-authentication-token-expiration' => ['2026-12-01 10:00:00 UTC']], + ]; + + /** @var list> */ + private array $calls = []; + + /** + * @return array> + */ + private static function scenarios(): array { + $list = ['plain' => []]; + $tokens = ['classic' => 'ghp_classic123', 'fine-grained' => 'github_pat_fine123', 'other' => 'glpat-other']; + foreach (['github', 'codeberg', 'forgejo', 'nope', ''] as $forge) { + foreach ($tokens as $kind => $token) { + foreach (['throw', 401, 403, 404, 500, 200] as $probe) { + $list["{$forge} {$kind} probe {$probe}"] = ['forge' => $forge, 'token' => $token, 'probe' => $probe, 'forgejo' => $forge === 'forgejo']; + } + } + } + $list['forgejo not configured'] = ['forge' => 'forgejo']; + $list['allow local addresses'] = ['allowLocal' => true]; + + $scopeHeaders = [ + 'no headers' => [], + 'empty scopes' => ['X-OAuth-Scopes' => ['']], + 'blank scopes' => ['X-OAuth-Scopes' => [' , ']], + 'public_repo' => ['X-OAuth-Scopes' => ['public_repo']], + 'repo and public_repo' => ['X-OAuth-Scopes' => ['repo, public_repo']], + 'extra scope' => ['X-OAuth-Scopes' => ['repo, read:org']], + 'two extra scopes' => ['X-OAuth-Scopes' => ['admin:org,repo,delete_repo']], + 'string header' => ['X-OAuth-Scopes' => 'repo, gist'], + 'lowercase header' => ['x-oauth-scopes' => ['gist']], + 'empty header list' => ['X-OAuth-Scopes' => []], + 'non-string header value' => ['X-OAuth-Scopes' => [5]], + 'numeric header' => ['X-OAuth-Scopes' => 7], + 'other header first' => ['X-Other' => ['x'], 'X-OAuth-Scopes' => ['repo']], + ]; + $expiry = [ + 'expiry missing' => [], + 'expiry blank' => ['github-authentication-token-expiration' => [' ']], + 'expiry invalid' => ['github-authentication-token-expiration' => ['not a date']], + 'expiry string' => ['github-authentication-token-expiration' => '2027-01-02T03:04:05+02:00'], + 'expiry uppercase key' => ['GitHub-Authentication-Token-Expiration' => ['2027-05-06 07:08:09 UTC']], + ]; + foreach ($tokens as $kind => $token) { + foreach ($scopeHeaders as $name => $headers) { + $list["{$kind}, {$name}"] = ['token' => $token, 'headers' => $headers + ['github-authentication-token-expiration' => ['2026-12-01 10:00:00 UTC']]]; + } + foreach ($expiry as $name => $headers) { + $list["{$kind}, {$name}"] = ['token' => $token, 'headers' => ['X-OAuth-Scopes' => ['repo']] + $headers]; + } + } + + return $list; + } + + public function testEveryScenarioMatchesTheGolden(): void { + $actual = []; + foreach (self::scenarios() as $name => $over) { + $actual[$name] = $this->runScenario(array_replace(self::BASE, $over)); + } + + if (getenv('RECORD_GOLDEN') === '1') { + if (!is_dir(dirname(self::FIXTURE))) { + mkdir(dirname(self::FIXTURE)); + } + file_put_contents(self::FIXTURE, json_encode($actual, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"); + $this->markTestSkipped('Golden fixture recorded.'); + } + + /** @var array $expected */ + $expected = json_decode((string)file_get_contents(self::FIXTURE), true); + self::assertSame(array_keys($expected), array_keys($actual), 'scenario list changed'); + foreach ($actual as $name => $case) { + self::assertSame( + json_encode($expected[$name], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + json_encode($case, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + "scenario {$name}", + ); + } + } + + /** + * @param array $s + * @return array + */ + private function runScenario(array $s): array { + $this->calls = []; + + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturnCallback( + static fn (string $app, string $key, string $default = ''): string => $s['forgejo'] === true && $key === 'forge.forgejo.web_base' ? 'https://git.example.org/' : $default + ); + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueBool')->willReturnCallback(function (string $key, bool $default) use ($s): bool { + $this->calls[] = ['config.bool', $key, $default]; + + return (bool)$s['allowLocal']; + }); + + $logger = $this->createMock(LoggerInterface::class); + $logger->method('warning')->willReturnCallback(function (string $message, array $context = []): void { + $this->calls[] = ['logger.warning', $message, $context]; + }); + + $client = $this->createMock(IClient::class); + $client->method('get')->willReturnCallback(function (string $url, array $options) use ($s): IResponse { + $this->calls[] = ['http.get', $url, $options]; + if ($s['probe'] === 'throw') { + throw new Exception('Connection refused.'); + } + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn((int)$s['probe']); + $response->method('getHeaders')->willReturn((array)$s['headers']); + + return $response; + }); + $clientService = $this->createMock(IClientService::class); + $clientService->method('newClient')->willReturnCallback(function () use ($client): IClient { + $this->calls[] = ['http.newClient']; + + return $client; + }); + + $validator = new PatValidator($clientService, $logger, new ForgeRegistry($appConfig), $config); + $result = $validator->validate((string)$s['token'], (string)$s['forge']); + + return ['result' => get_object_vars($result), 'calls' => $this->calls]; + } +} diff --git a/tests/unit/Service/Pat/fixtures/pat-validate.golden.json b/tests/unit/Service/Pat/fixtures/pat-validate.golden.json new file mode 100644 index 00000000..1ba7658a --- /dev/null +++ b/tests/unit/Service/Pat/fixtures/pat-validate.golden.json @@ -0,0 +1,4479 @@ +{ + "plain": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github classic probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach api.github.com — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "github", + "errorMessage": "Connection refused." + } + ] + ] + }, + "github classic probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github classic probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github classic probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github classic probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github classic probe 200": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github fine-grained probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach api.github.com — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "github", + "errorMessage": "Connection refused." + } + ] + ] + }, + "github fine-grained probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github fine-grained probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github fine-grained probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github fine-grained probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github fine-grained probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github other probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach api.github.com — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "github", + "errorMessage": "Connection refused." + } + ] + ] + }, + "github other probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github other probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github other probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github other probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "api.github.com returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "github other probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg classic probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach codeberg.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "codeberg", + "errorMessage": "Connection refused." + } + ] + ] + }, + "codeberg classic probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg classic probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg classic probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg classic probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg classic probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Codeberg does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg fine-grained probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach codeberg.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "codeberg", + "errorMessage": "Connection refused." + } + ] + ] + }, + "codeberg fine-grained probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg fine-grained probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg fine-grained probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg fine-grained probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg fine-grained probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Codeberg does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg other probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach codeberg.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "codeberg", + "errorMessage": "Connection refused." + } + ] + ] + }, + "codeberg other probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg other probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg other probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg other probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "codeberg.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "codeberg other probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Codeberg does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://codeberg.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo classic probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach git.example.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "forgejo", + "errorMessage": "Connection refused." + } + ] + ] + }, + "forgejo classic probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo classic probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo classic probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo classic probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo classic probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Forgejo does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token ghp_classic123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo fine-grained probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach git.example.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "forgejo", + "errorMessage": "Connection refused." + } + ] + ] + }, + "forgejo fine-grained probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo fine-grained probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo fine-grained probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo fine-grained probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo fine-grained probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Forgejo does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token github_pat_fine123", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo other probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Could not reach git.example.org — check network connectivity." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ], + [ + "logger.warning", + "PatValidator: probe failed", + { + "forge": "forgejo", + "errorMessage": "Connection refused." + } + ] + ] + }, + "forgejo other probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Token is invalid or revoked." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo other probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "The rate limit was exceeded — try again later." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo other probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 404." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo other probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "git.example.org returned HTTP 500." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "forgejo other probe 200": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: Forgejo does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://git.example.org/api/v1/user", + { + "headers": { + "Authorization": "token glpat-other", + "Accept": "application/json", + "User-Agent": "Nextcloud-Versioniq" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "nope classic probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope classic probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope classic probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope classic probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope classic probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope classic probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope fine-grained probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + "nope other probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"nope\"." + }, + "calls": [] + }, + " classic probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " classic probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " classic probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " classic probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " classic probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " classic probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " fine-grained probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe throw": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe 401": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe 403": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe 404": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe 500": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + " other probe 200": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"\"." + }, + "calls": [] + }, + "forgejo not configured": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "Unknown forge \"forgejo\"." + }, + "calls": [] + }, + "allow local addresses": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": true + } + } + ] + ] + }, + "classic, no headers": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, empty scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, blank scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, public_repo": { + "result": { + "ok": true, + "scopes": [ + "public_repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, repo and public_repo": { + "result": { + "ok": true, + "scopes": [ + "repo", + "public_repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, extra scope": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "PAT has scopes beyond what Versioniq needs (read:org). Recreate with repo or public_repo only." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, two extra scopes": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "PAT has scopes beyond what Versioniq needs (admin:org, delete_repo). Recreate with repo or public_repo only." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, string header": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "PAT has scopes beyond what Versioniq needs (gist). Recreate with repo or public_repo only." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, lowercase header": { + "result": { + "ok": false, + "scopes": [], + "warnings": [], + "expiresAt": null, + "error": "PAT has scopes beyond what Versioniq needs (gist). Recreate with repo or public_repo only." + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, empty header list": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, non-string header value": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, numeric header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, other header first": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, expiry missing": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, expiry blank": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, expiry invalid": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, expiry string": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2027-01-02 03:04:05", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "classic, expiry uppercase key": { + "result": { + "ok": true, + "scopes": [ + "repo" + ], + "warnings": [], + "expiresAt": "2027-05-06 07:08:09", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer ghp_classic123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, no headers": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, empty scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, blank scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, public_repo": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, repo and public_repo": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, extra scope": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, two extra scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, string header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, lowercase header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, empty header list": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, non-string header value": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, numeric header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, other header first": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, expiry missing": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, expiry blank": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, expiry invalid": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, expiry string": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2027-01-02 03:04:05", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "fine-grained, expiry uppercase key": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2027-05-06 07:08:09", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer github_pat_fine123", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, no headers": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, empty scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, blank scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, public_repo": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, repo and public_repo": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, extra scope": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, two extra scopes": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, string header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, lowercase header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, empty header list": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, non-string header value": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, numeric header": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, other header first": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2026-12-01 10:00:00", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, expiry missing": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, expiry blank": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, expiry invalid": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": null, + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, expiry string": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2027-01-02 03:04:05", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + }, + "other, expiry uppercase key": { + "result": { + "ok": true, + "scopes": [], + "warnings": [ + "unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only)." + ], + "expiresAt": "2027-05-06 07:08:09", + "error": null + }, + "calls": [ + [ + "http.newClient" + ], + [ + "config.bool", + "allow_local_remote_servers", + false + ], + [ + "http.get", + "https://api.github.com/user", + { + "headers": { + "Authorization": "Bearer glpat-other", + "Accept": "application/vnd.github+json", + "User-Agent": "Nextcloud-Versioniq", + "X-GitHub-Api-Version": "2022-11-28" + }, + "timeout": 30, + "http_errors": false, + "nextcloud": { + "allow_local_address": false + } + } + ] + ] + } +} From 83a713d86b5d0ce237226cf93c9c2761ba29269a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 09:19:42 +0100 Subject: [PATCH 2/2] refactor(pat): split PatValidator::validate into probe, status and scope steps The forge probe, the status answers, and the scope and expiry evaluation are separate steps, each taking at most two parameters. Behaviour, the probe request and every result are unchanged (pinned by the golden scenario set). Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/Pat/PatValidator.php | 133 +++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 42 deletions(-) diff --git a/lib/Service/Pat/PatValidator.php b/lib/Service/Pat/PatValidator.php index e7e3e12d..8dbdff1f 100644 --- a/lib/Service/Pat/PatValidator.php +++ b/lib/Service/Pat/PatValidator.php @@ -14,8 +14,10 @@ use Exception; use OCA\Versioniq\Db\Pat; +use OCA\Versioniq\Service\Source\Forge; use OCA\Versioniq\Service\Source\ForgeRegistry; use OCP\Http\Client\IClientService; +use OCP\Http\Client\IResponse; use OCP\IConfig; use Psr\Log\LoggerInterface; @@ -76,24 +78,40 @@ public function validate(string $token, string $forge = ForgeRegistry::FORGE_GIT } $f = $this->forgeRegistry->get($forge); $kind = $this->detectKind($token); - $client = $this->clientService->newClient(); - $headers = [ - 'Authorization' => $f->authHeaderValue($token), - 'Accept' => 'application/json', - 'User-Agent' => self::USER_AGENT, - ]; - if ($f->id === ForgeRegistry::FORGE_GITHUB) { - $headers['Accept'] = 'application/vnd.github+json'; - $headers['X-GitHub-Api-Version'] = '2022-11-28'; + $response = $this->probe($f, $token); + if ($response instanceof ValidationResult) { + return $response; } - $host = parse_url($f->apiBaseUrl, PHP_URL_HOST); - $hostLabel = is_string($host) ? $host : $f->id; + $rejection = $this->statusRejection($f, $response->getStatusCode()); + if ($rejection !== null) { + return $rejection; + } + + // Forges that do not expose token scopes to the holder (e.g. + // Codeberg/Forgejo, GitHub fine-grained PATs) are accepted best-effort + // with an explicit warning to verify least privilege manually. + if (!$f->exposesScopeHeader) { + return ValidationResult::accepted( + [], + [sprintf('unverifiable_scope: %s does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only).', ucfirst($f->id))], + null, + ); + } + + return $this->scopeResult($kind, $response->getHeaders()); + } + + /** + * Calls the forge's user endpoint with the token; a rejection when the forge cannot be reached. + */ + private function probe(Forge $f, string $token): IResponse|ValidationResult { + $client = $this->clientService->newClient(); try { - $response = $client->get($f->userEndpoint(), [ - 'headers' => $headers, + return $client->get($f->userEndpoint(), [ + 'headers' => $this->probeHeaders($f, $token), 'timeout' => 30, // IClient (Guzzle) throws on 4xx by default; we want to inspect // the status ourselves so we can produce a useful error message. @@ -103,10 +121,37 @@ public function validate(string $token, string $forge = ForgeRegistry::FORGE_GIT } catch (Exception $error) { $this->logger->warning('PatValidator: probe failed', ['forge' => $f->id, 'errorMessage' => $error->getMessage()]); - return ValidationResult::rejected(sprintf('Could not reach %s — check network connectivity.', $hostLabel)); + return ValidationResult::rejected(sprintf('Could not reach %s — check network connectivity.', $this->hostLabel($f))); } + } - $status = $response->getStatusCode(); + /** + * @return array + */ + private function probeHeaders(Forge $f, string $token): array { + $headers = [ + 'Authorization' => $f->authHeaderValue($token), + 'Accept' => 'application/json', + 'User-Agent' => self::USER_AGENT, + ]; + if ($f->id === ForgeRegistry::FORGE_GITHUB) { + $headers['Accept'] = 'application/vnd.github+json'; + $headers['X-GitHub-Api-Version'] = '2022-11-28'; + } + + return $headers; + } + + private function hostLabel(Forge $f): string { + $host = parse_url($f->apiBaseUrl, PHP_URL_HOST); + + return is_string($host) ? $host : $f->id; + } + + /** + * The rejection for a probe status other than 200, else null. + */ + private function statusRejection(Forge $f, int $status): ?ValidationResult { if ($status === 401) { return ValidationResult::rejected('Token is invalid or revoked.'); } @@ -114,42 +159,27 @@ public function validate(string $token, string $forge = ForgeRegistry::FORGE_GIT return ValidationResult::rejected('The rate limit was exceeded — try again later.'); } if ($status !== 200) { - return ValidationResult::rejected(sprintf('%s returned HTTP %d.', $hostLabel, $status)); + return ValidationResult::rejected(sprintf('%s returned HTTP %d.', $this->hostLabel($f), $status)); } - // Forges that do not expose token scopes to the holder (e.g. - // Codeberg/Forgejo, GitHub fine-grained PATs) are accepted best-effort - // with an explicit warning to verify least privilege manually. - if (!$f->exposesScopeHeader) { - return ValidationResult::accepted( - [], - [sprintf('unverifiable_scope: %s does not expose token permissions to the holder; please verify the token is read-only (repository contents read access only).', ucfirst($f->id))], - null, - ); - } + return null; + } - $headers = $response->getHeaders(); + /** + * Enforces least privilege from the scope header for a classic token; a + * fine-grained token is accepted with a warning, as GitHub does not expose + * its permissions. + * + * @param array $headers + */ + private function scopeResult(string $kind, array $headers): ValidationResult { $scopesHeader = $this->headerValue($headers, 'X-OAuth-Scopes'); $expiresAt = $this->parseExpires($this->headerValue($headers, 'github-authentication-token-expiration')); if ($kind === Pat::KIND_CLASSIC) { - $scopes = $this->parseScopes($scopesHeader); - $disallowed = array_values(array_filter( - $scopes, - static fn (string $scope): bool => !in_array($scope, self::ALLOWED_CLASSIC_SCOPES, true) - )); - if ($disallowed !== []) { - return ValidationResult::rejected(sprintf( - 'PAT has scopes beyond what Versioniq needs (%s). Recreate with %s only.', - implode(', ', $disallowed), - implode(' or ', self::ALLOWED_CLASSIC_SCOPES) - )); - } - - return ValidationResult::accepted($scopes, [], $expiresAt); + return $this->classicScopeResult($this->parseScopes($scopesHeader), $expiresAt); } - // Fine-grained PAT return ValidationResult::accepted( [], ['unverifiable_scope: GitHub did not expose configured permissions; please verify they are read-only (Contents: Read-only, Metadata: Read-only).'], @@ -157,6 +187,25 @@ public function validate(string $token, string $forge = ForgeRegistry::FORGE_GIT ); } + /** + * @param list $scopes + */ + private function classicScopeResult(array $scopes, ?string $expiresAt): ValidationResult { + $disallowed = array_values(array_filter( + $scopes, + static fn (string $scope): bool => !in_array($scope, self::ALLOWED_CLASSIC_SCOPES, true) + )); + if ($disallowed !== []) { + return ValidationResult::rejected(sprintf( + 'PAT has scopes beyond what Versioniq needs (%s). Recreate with %s only.', + implode(', ', $disallowed), + implode(' or ', self::ALLOWED_CLASSIC_SCOPES) + )); + } + + return ValidationResult::accepted($scopes, [], $expiresAt); + } + /** * @param array $headers */