From 5d7489c8ed661c369fd5eff5e225f103c7a3961f Mon Sep 17 00:00:00 2001 From: Oleksander Piskun Date: Tue, 15 Sep 2026 12:26:29 +0000 Subject: [PATCH] fix(integration-test): run the OAuth, notification and revocation tests without a GitHub account The OAuth login, notification and token revocation tests signed in to github.com as a CI account with a password and a TOTP code, and failed or were skipped whenever GitHub answered with a two-factor prompt or a checkup page. They now build GithubAPIService and ConfigController with a mocked HTTP client and assert the requests the app sends: the OAuth code exchange and user lookup, a rejected state mismatch, the notification filter, and the token revocation. The HTML scraping helpers are removed, and the integration workflow no longer passes the CI_* secrets. Signed-off-by: Oleksander Piskun --- .github/workflows/integration.yml | 5 - tests/integration/GitHubHtml.php | 178 ----- .../GitHubNotificationsIntegrationTest.php | 112 ++-- .../GithubOauthIntegrationTest.php | 610 +++--------------- .../GithubTokenRevocationIntegrationTest.php | 94 +++ .../GithubZTokenRevocationIntegrationTest.php | 50 -- tests/integration/MockedGithubApiTrait.php | 146 +++++ tests/integration/Totp.php | 67 -- tests/integration/WorkflowTokenTrait.php | 7 +- 9 files changed, 391 insertions(+), 878 deletions(-) delete mode 100644 tests/integration/GitHubHtml.php create mode 100644 tests/integration/GithubTokenRevocationIntegrationTest.php delete mode 100644 tests/integration/GithubZTokenRevocationIntegrationTest.php create mode 100644 tests/integration/MockedGithubApiTrait.php delete mode 100644 tests/integration/Totp.php diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 65414114..bae8d598 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -84,11 +84,6 @@ jobs: - name: PHPUnit integration working-directory: apps/${{ env.APP_NAME }} env: - CI_CLIENT_ID: ${{ secrets.CI_CLIENT_ID }} - CI_CLIENT_SECRET: ${{ secrets.CI_CLIENT_SECRET }} - CI_USER_LOGIN: ${{ secrets.CI_USER_LOGIN }} - CI_USER_PASSWORD: ${{ secrets.CI_USER_PASSWORD }} - CI_TOTP_SECRET: ${{ secrets.CI_TOTP_SECRET }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: composer run test:integration diff --git a/tests/integration/GitHubHtml.php b/tests/integration/GitHubHtml.php deleted file mode 100644 index 81ee0b57..00000000 --- a/tests/integration/GitHubHtml.php +++ /dev/null @@ -1,178 +0,0 @@ -loadHTML($body); - - libxml_clear_errors(); - - return new DOMXPath($doc); - } - - public static function getPageTitle(DOMXPath $selector): string { - $title = $selector->query('//title')?->item(0)?->textContent; - return $title === null ? 'no title' : trim($title); - } - - public static function findForm(DOMXPath $selector, array $formSelectors): ?DOMElement { - foreach ($formSelectors as $formSelector) { - $result = $selector->query($formSelector); - $form = $result?->item(0); - if ($form instanceof DOMElement) { - return $form; - } - } - - return null; - } - - public static function findAuthorizeForm(DOMXPath $selector): ?DOMElement { - return self::findForm($selector, [ - '//form[contains(@class, "js-oauth-authorize-form")]', - '//form[@action="/login/oauth/authorize"]', - '//form[contains(@action, "authorize")]', - ]); - } - - public static function findTwoFactorForm(DOMXPath $selector): ?DOMElement { - return self::findForm($selector, [ - '//form[contains(@action, "two-factor") and .//input[@name="app_otp" or @name="otp"]]', - '//form[.//input[@name="app_otp" or @name="otp"]]', - ]); - } - - public static function findTwoFactorCheckupDelayForm(DOMXPath $selector): ?DOMElement { - return self::findForm($selector, [ - '//form[@action="/settings/two_factor_checkup/delay"]', - '//form[contains(@action, "two_factor_checkup/delay")]', - ]); - } - - /** - * Whether this is GitHub's "Verify your two-factor authentication (2FA) settings" - * checkup page, regardless of whether a dismissable delay form is present. - * - * The page is sometimes served with only a client-rendered - * `/settings/two_factor_checkup` form carrying no named inputs, which cannot be - * posted back. Detecting it separately from the delay form lets the caller report - * that specifically instead of failing with a generic "no form found". - */ - public static function isTwoFactorCheckupPage(DOMXPath $selector, string $url = ''): bool { - if (str_contains($url, 'two_factor_checkup')) { - return true; - } - - $checkupForm = self::findForm($selector, [ - '//form[contains(@action, "two_factor_checkup")]', - ]); - - return $checkupForm !== null; - } - - public static function findTotpAlternativeUrl(DOMXPath $selector): ?string { - $linkSelectors = [ - '//a[contains(@href, "two-factor/app")]', - '//a[contains(@href, "totp")]', - '//a[contains(text(), "authenticator")]', - '//a[contains(text(), "authentication app")]', - '//a[contains(text(), "Use your authenticator")]', - ]; - foreach ($linkSelectors as $linkSelector) { - $result = $selector->query($linkSelector); - $link = $result?->item(0); - if ($link instanceof DOMElement && $link->hasAttribute('href')) { - return self::resolveUrl($link->getAttribute('href'), 'https://github.com/sessions/two-factor/app'); - } - } - - return null; - } - - public static function resolveUrl(string $url, string $fallbackUrl): string { - if ($url === '') { - return $fallbackUrl; - } - if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { - return $url; - } - if (str_starts_with($url, '/')) { - return 'https://github.com' . $url; - } - - $baseUrl = preg_replace('/[^\/]+$/', '', $fallbackUrl); - return $baseUrl . $url; - } - - public static function extractFormInputs(DOMXPath $selector, DOMElement $form): array { - $formParams = []; - $inputs = $selector->query('.//input[@name] | .//button[@name]', $form); - foreach ($inputs as $input) { - $name = $input->getAttribute('name'); - $value = $input->getAttribute('value'); - $type = $input->getAttribute('type'); - if ($type === 'checkbox' && !$input->hasAttribute('checked')) { - continue; - } - $formParams[$name] = $value; - } - - return $formParams; - } - - /** - * Summarize the forms and headings on a page for failure diagnostics. - * Emits form actions and input names (never values) plus h1/h2 text, - * so CI logs show what GitHub actually returned without leaking tokens. - */ - public static function describePage(DOMXPath $selector): string { - $parts = []; - - $forms = $selector->query('//form'); - if ($forms !== false) { - foreach ($forms as $form) { - if (!$form instanceof DOMElement) { - continue; - } - $action = $form->getAttribute('action'); - $inputNodes = $selector->query('.//input[@name] | .//button[@name]', $form); - $names = []; - if ($inputNodes !== false) { - foreach ($inputNodes as $input) { - if ($input instanceof DOMElement) { - $names[] = $input->getAttribute('name'); - } - } - } - $parts[] = 'form(action=' . ($action === '' ? '' : $action) . ', inputs=[' . implode(',', $names) . '])'; - } - } - - $headings = $selector->query('//h1 | //h2'); - if ($headings !== false) { - foreach ($headings as $heading) { - $text = trim($heading->textContent); - if ($text !== '') { - $parts[] = $heading->nodeName . '=' . mb_substr($text, 0, 120); - } - } - } - - return $parts === [] ? '' : implode(' | ', $parts); - } -} diff --git a/tests/integration/GitHubNotificationsIntegrationTest.php b/tests/integration/GitHubNotificationsIntegrationTest.php index cdea0451..68587210 100644 --- a/tests/integration/GitHubNotificationsIntegrationTest.php +++ b/tests/integration/GitHubNotificationsIntegrationTest.php @@ -9,39 +9,60 @@ namespace OCA\Github\Tests\Integration; +require_once __DIR__ . '/MockedGithubApiTrait.php'; + use OCA\Github\Service\GithubAPIService; use OCA\Github\Service\SecretService; use OCP\Server; -use PHPUnit\Framework\Attributes\DependsExternal; use PHPUnit\Framework\Attributes\Group; use Test\TestCase; #[Group('DB')] class GitHubNotificationsIntegrationTest extends TestCase { + use MockedGithubApiTrait; + + private const ACCESS_TOKEN = 'gho_test_access_token'; + private GithubAPIService $githubAPIService; - private SecretService $secretService; protected function setUp(): void { parent::setUp(); - $this->githubAPIService = Server::get(GithubAPIService::class); - $this->secretService = Server::get(SecretService::class); + $this->useTestUser(); + Server::get(SecretService::class)->setEncryptedUserValue(self::TEST_USER_ID, 'token', self::ACCESS_TOKEN); + $this->githubAPIService = $this->createGithubAPIService(); } - #[DependsExternal(GithubOauthIntegrationTest::class, 'testOAuthLogin')] - public function testGetNotificationsStructure(array $oauthData): void { - $this->assertIsArray($oauthData, 'oauthData should be an array from OAuth test, got: ' . gettype($oauthData)); - $this->assertArrayHasKey('userId', $oauthData, 'oauthData must contain userId'); - $userId = $oauthData['userId']; - - $token = $this->secretService->getEncryptedUserValue($userId, 'token'); - $this->assertNotSame('', $token, 'Token should be stored after OAuth flow'); + protected function tearDown(): void { + $this->resetTestUserConfig(); + parent::tearDown(); + } - $notifications = $this->githubAPIService->getNotifications($userId); + public function testGetNotificationsStructure(): void { + $this->client->expects($this->once()) + ->method('get') + ->with( + $this->callback(function (string $url): bool { + $this->assertStringStartsWith('https://api.github.com/notifications?', $url); + parse_str((string)parse_url($url, PHP_URL_QUERY), $query); + $this->assertArrayHasKey('since', $query, 'The request should be limited to recent notifications'); + $this->assertEqualsWithDelta(time() - 14 * 24 * 3600, strtotime((string)$query['since']), 300, 'since should default to two weeks ago'); + return true; + }), + [ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => 'token ' . self::ACCESS_TOKEN, + ], + ], + ) + ->willReturn($this->mockResponse(200, json_encode(self::notifications()))); + + $notifications = $this->githubAPIService->getNotifications(self::TEST_USER_ID); $this->assertArrayNotHasKey('error', $notifications, 'GitHub API returned error: ' . ($notifications['error'] ?? 'unknown')); - $this->assertIsArray($notifications, 'Notifications should be an array'); - + $this->assertNotEmpty($notifications, 'The interesting notifications should be returned'); foreach ($notifications as $notification) { $this->assertNotificationStructure($notification); } @@ -118,30 +139,49 @@ private function computeExpectedTargetUrl(string $subjectType, string $subjectUr return 'https://github.com/' . $repoFullName . '/discussions'; } - #[DependsExternal(GithubOauthIntegrationTest::class, 'testOAuthLogin')] - public function testNotificationFilteringLogic(array $oauthData): void { - $this->assertIsArray($oauthData, 'oauthData should be an array from OAuth test, got: ' . gettype($oauthData)); - $this->assertArrayHasKey('userId', $oauthData, 'oauthData must contain userId'); - $userId = $oauthData['userId']; + public function testNotificationFilteringLogic(): void { + $this->client->method('get') + ->willReturn($this->mockResponse(200, json_encode(self::notifications()))); - $token = $this->secretService->getEncryptedUserValue($userId, 'token'); - $this->assertNotSame('', $token, 'Token should be stored after OAuth flow'); + $notifications = $this->githubAPIService->getNotifications(self::TEST_USER_ID); + $this->assertSame(['1', '3', '5'], array_column($notifications, 'id'), + 'Only unread notifications with an interesting reason, or subscribed releases, should be kept'); - $notifications = $this->githubAPIService->getNotifications($userId); - $this->assertArrayNotHasKey('error', $notifications, 'GitHub API returned error: ' . ($notifications['error'] ?? 'unknown')); - - $validReasons = ['assign', 'mention', 'team_mention', 'review_requested', 'author', 'manual']; - - foreach ($notifications as $notification) { - $reason = $notification['reason'] ?? ''; - $subjectType = $notification['subject']['type'] ?? ''; - $unread = $notification['unread'] ?? false; + $limited = $this->githubAPIService->getNotifications(self::TEST_USER_ID, null, null, 2); + $this->assertSame(['1', '3'], array_column($limited, 'id'), 'The result should be limited'); + } - $isValidReason = in_array($reason, $validReasons, true) - || ($reason === 'subscribed' && $subjectType === 'Release'); + /** + * A kept and a dropped case for each branch of the filter in GithubAPIService::getNotifications(). + */ + private static function notifications(): array { + return [ + self::notification('1', 'mention', true, 'Issue', 'https://api.github.com/repos/nextcloud/server/issues/1'), + self::notification('2', 'assign', false, 'Issue', 'https://api.github.com/repos/nextcloud/server/issues/2'), + self::notification('3', 'subscribed', true, 'Release', 'https://api.github.com/repos/nextcloud/server/releases/3'), + self::notification('4', 'subscribed', true, 'Issue', 'https://api.github.com/repos/nextcloud/server/issues/4'), + self::notification('5', 'review_requested', true, 'PullRequest', 'https://api.github.com/repos/nextcloud/server/pulls/5'), + self::notification('6', 'ci_activity', true, 'CheckSuite', ''), + ]; + } - $this->assertTrue($isValidReason, "Notification has unexpected reason: $reason"); - $this->assertTrue($unread, 'Notification should be unread'); - } + private static function notification(string $id, string $reason, bool $unread, string $type, string $subjectUrl): array { + return [ + 'id' => $id, + 'unread' => $unread, + 'reason' => $reason, + 'updated_at' => '2026-09-01T12:00:00Z', + 'subject' => [ + 'title' => 'Notification ' . $id, + 'type' => $type, + 'url' => $subjectUrl, + ], + 'repository' => [ + 'name' => 'server', + 'full_name' => 'nextcloud/server', + 'html_url' => 'https://github.com/nextcloud/server', + 'owner' => ['login' => 'nextcloud'], + ], + ]; } } diff --git a/tests/integration/GithubOauthIntegrationTest.php b/tests/integration/GithubOauthIntegrationTest.php index a485768a..190fc5ed 100644 --- a/tests/integration/GithubOauthIntegrationTest.php +++ b/tests/integration/GithubOauthIntegrationTest.php @@ -9,564 +9,98 @@ namespace OCA\Github\Tests\Integration; -require_once __DIR__ . '/GitHubHtml.php'; -require_once __DIR__ . '/Totp.php'; +require_once __DIR__ . '/MockedGithubApiTrait.php'; -use GuzzleHttp\Client; -use GuzzleHttp\Cookie\CookieJar; -use GuzzleHttp\RequestOptions; use OCA\Github\AppInfo\Application; use OCA\Github\Controller\ConfigController; -use OCA\Github\Service\GithubAPIService; use OCA\Github\Service\SecretService; -use OCP\App\IAppManager; -use OCP\AppFramework\Http; use OCP\IConfig; -use OCP\IURLGenerator; -use OCP\IUserManager; -use OCP\IUserSession; use OCP\Server; use PHPUnit\Framework\Attributes\Group; use Test\TestCase; #[Group('DB')] class GithubOauthIntegrationTest extends TestCase { - private const TEST_USER_ID = 'github_test_user'; - private const OAUTH_SCOPE = 'read:user user:email repo notifications'; - private const MAX_GITHUB_REDIRECTS = 10; + use MockedGithubApiTrait; + + private const CLIENT_ID = 'test-client-id'; + private const CLIENT_SECRET = 'test-client-secret'; + private const ACCESS_TOKEN = 'gho_test_access_token'; + private const CODE = 'test-oauth-code'; + private const STATE = 'test-oauth-state'; - private ConfigController $configController; - private ?string $githubClientId; - private ?string $githubClientSecret; - private ?string $githubLogin; - private ?string $githubPassword; - private ?string $githubTotpSecret; - private Client $client; - private CookieJar $cookieJar; private IConfig $config; - private GithubAPIService $githubAPIService; private SecretService $secretService; - private ?string $userId; - private IURLGenerator $urlGenerator; - - private function resetUserConfig(string $userId): void { - foreach ([ - 'token', - 'token_type', - 'user_id', - 'user_name', - 'user_displayname', - 'oauth_state', - 'redirect_uri', - 'oauth_origin', - 'navigation_enabled', - 'search_issues_enabled', - 'search_repos_enabled', - 'link_preview_enabled', - ] as $key) { - $this->config->deleteUserValue($userId, Application::APP_ID, $key); - } - } + private ConfigController $configController; protected function setUp(): void { parent::setUp(); - $appManager = Server::get(IAppManager::class); - $appManager->enableApp(Application::APP_ID); - - $this->githubClientId = getenv('CI_CLIENT_ID') ?: null; - $this->githubClientSecret = getenv('CI_CLIENT_SECRET') ?: null; - $this->githubLogin = getenv('CI_USER_LOGIN') ?: null; - $this->githubPassword = getenv('CI_USER_PASSWORD') ?: null; - $this->githubTotpSecret = getenv('CI_TOTP_SECRET') ?: null; - - $userManager = Server::get(IUserManager::class); - $user = $userManager->get(self::TEST_USER_ID) - ?? $userManager->createUser(self::TEST_USER_ID, 'test-password'); - self::loginAsUser($user->getUID()); + $this->useTestUser(); + $this->useOAuthApp(self::CLIENT_ID, self::CLIENT_SECRET); $this->config = Server::get(IConfig::class); - $this->resetUserConfig($user->getUID()); - - $this->configController = Server::get(ConfigController::class); - $this->githubAPIService = Server::get(GithubAPIService::class); $this->secretService = Server::get(SecretService::class); - $this->urlGenerator = Server::get(IURLGenerator::class); - $this->userId = Server::get(IUserSession::class)->getUser()?->getUID(); - $this->newClient(); - } - - private function newClient(): void { - $this->cookieJar = new CookieJar(); - $this->client = new Client([ - 'allow_redirects' => [ - 'track_redirects' => true, - 'max' => self::MAX_GITHUB_REDIRECTS, - ], - 'cookies' => $this->cookieJar, - 'http_errors' => false, - 'headers' => [ - 'User-Agent' => 'Nextcloud-GitHub-Integration-Test', - ], - ]); - } - - private function requireCredentials(): void { - if ($this->githubClientId === null || $this->githubClientSecret === null) { - $this->markTestSkipped('CI_CLIENT_ID and/or CI_CLIENT_SECRET not set'); - } - if ($this->githubLogin === null || $this->githubPassword === null) { - $this->markTestSkipped('CI_USER_LOGIN and/or CI_USER_PASSWORD not set'); - } - if ($this->githubTotpSecret === null) { - $this->markTestSkipped('CI_TOTP_SECRET not set'); - } - if ($this->userId === null || $this->userId === '') { - $this->markTestSkipped('No Nextcloud user is available for the OAuth integration test'); - } - } - - private function makeOAuthState(): string { - return bin2hex(random_bytes(16)); - } - - private function getOAuthAuthorizeUrl(string $oauthState): string { - $redirectUri = $this->urlGenerator->linkToRouteAbsolute('integration_github.config.oauthRedirect'); - - $query = http_build_query([ - 'client_id' => $this->githubClientId, - 'redirect_uri' => $redirectUri, - 'state' => $oauthState, - 'scope' => self::OAUTH_SCOPE, - ], arg_separator: '&', encoding_type: PHP_QUERY_RFC3986); - - return 'https://github.com/login/oauth/authorize?' . $query; - } - - private function getPageContext(string $body): array { - $selector = GitHubHtml::loadXPath($body); - return [ - 'selector' => $selector, - 'title' => GitHubHtml::getPageTitle($selector), - ]; - } - - private function assertOkStatus(int $statusCode, string $message): void { - if ($statusCode !== Http::STATUS_OK) { - $this->fail($message . ' Status: ' . $statusCode); - } - } - - private function setOtpCode(array $formParams, string $totpCode): array { - if (array_key_exists('otp', $formParams)) { - $formParams['otp'] = $totpCode; - } else { - $formParams['app_otp'] = $totpCode; - } - - return $formParams; - } - - private function isInvalidTotpResponse(string $responseBody): bool { - return str_contains($responseBody, 'Incorrect code') - || str_contains($responseBody, 'Invalid two-factor authentication code'); - } - - private function requestFollowingGitHubRedirects(string $method, string $url, array $options = []): array { - $currentMethod = $method; - $currentUrl = $url; - $currentOptions = $options; - - for ($i = 0; $i < self::MAX_GITHUB_REDIRECTS; $i++) { - $currentOptions[RequestOptions::ALLOW_REDIRECTS] = false; - $currentOptions[RequestOptions::HTTP_ERRORS] = false; - - $response = $this->client->request($currentMethod, $currentUrl, $currentOptions); - $statusCode = $response->getStatusCode(); - $body = $response->getBody()->getContents(); - $location = $response->getHeaderLine('Location'); - - if ($statusCode < 300 || $statusCode >= 400 || $location === '') { - return [ - 'response' => $response, - 'body' => $body, - 'final_url' => $currentUrl, - ]; - } - - $redirectUrl = GitHubHtml::resolveUrl($location, $currentUrl); - $redirectHost = parse_url($redirectUrl, PHP_URL_HOST); - if ($redirectHost !== 'github.com') { - return [ - 'response' => $response, - 'body' => $body, - 'final_url' => $redirectUrl, - 'stopped_before_external_redirect' => true, - ]; - } - - if (in_array($statusCode, [Http::STATUS_MOVED_PERMANENTLY, Http::STATUS_FOUND, Http::STATUS_SEE_OTHER], true)) { - $currentMethod = 'GET'; - $currentUrl = $redirectUrl; - $currentOptions = []; - continue; - } - - if (in_array($statusCode, [Http::STATUS_TEMPORARY_REDIRECT, 308], true)) { - $currentUrl = $redirectUrl; - continue; - } - - $this->fail('Unexpected GitHub redirect status ' . $statusCode . ' while requesting ' . $currentUrl . '. Redirect URL: ' . $redirectUrl); - } - - $this->fail('Too many GitHub redirects while requesting ' . $url); - } - - private function interpretAuthenticatedResponse(string $body, string $finalUrl, string $step): array { - if (str_contains($finalUrl, 'sessions/verified-device')) { - $this->fail('GitHub redirected to device verification after the ' . $step . ' step. CI runners look like new devices, so the test account must use app-based 2FA instead of email-based device verification. Final URL: ' . $finalUrl); - } - - if (str_contains($finalUrl, 'code=')) { - return [ - 'status' => 'redirect_with_code', - 'redirect_url' => $finalUrl, - 'body' => $body, - ]; - } - - ['selector' => $selector, 'title' => $title] = $this->getPageContext($body); - - // GitHub periodically interrupts authenticated navigation with a "Verify your - // two-factor authentication (2FA) settings" checkup page. It is not a real 2FA - // challenge, just a reminder; POSTing the delay form dismisses it. - if (GitHubHtml::findTwoFactorCheckupDelayForm($selector) !== null) { - return [ - 'status' => 'two_factor_checkup', - 'checkup_url' => $finalUrl, - 'body' => $body, - ]; - } - - // The same checkup, but served with only a client-rendered form carrying no - // named inputs. There is nothing to post back, so it cannot be dismissed from - // here; report it distinctly so the caller can skip rather than fail on what is - // account state rather than a regression. - if (GitHubHtml::isTwoFactorCheckupPage($selector, $finalUrl)) { - return [ - 'status' => 'two_factor_checkup_blocked', - 'checkup_url' => $finalUrl, - 'body' => $body, - ]; - } - - $isTwoFactorPage = GitHubHtml::findTwoFactorForm($selector) !== null - || str_contains($finalUrl, 'two-factor') - || str_contains($title, 'Two-factor authentication'); - if ($isTwoFactorPage) { - return [ - 'status' => 'two_factor_required', - 'two_factor_url' => $finalUrl, - 'body' => $body, - ]; - } - - if (GitHubHtml::findAuthorizeForm($selector) !== null) { - return [ - 'status' => 'authorize_page', - 'authorize_url' => $finalUrl, - 'body' => $body, - ]; - } - - if (str_contains($title, 'Sign in to GitHub')) { - $this->fail('GitHub returned the sign-in page after the ' . $step . ' step. This usually means the authenticated session was not established or cookies were not kept. Final URL: ' . $finalUrl); - } - - $this->fail( - 'GitHub completed the ' . $step . ' step but neither a 2FA form, an authorize form, nor a callback redirect with code was found. ' - . 'Final URL: ' . $finalUrl . '. Page title: ' . $title . '. ' - . 'Page: ' . GitHubHtml::describePage($selector) - ); - } - - private function loginToGitHub(string $authorizeUrl): array { - $response = $this->client->get($authorizeUrl); - $statusCode = $response->getStatusCode(); - $body = $response->getBody()->getContents(); - - $this->assertOkStatus($statusCode, 'Initial OAuth authorize request failed for URL ' . $authorizeUrl . '.'); - - ['selector' => $selector, 'title' => $title] = $this->getPageContext($body); - - $loginForm = GitHubHtml::findForm($selector, [ - '//form[@action="/session"]', - '//form[contains(@class, "session-authentication")]', - '//form[@id="login_form"]', - ]); - if ($loginForm === null) { - $this->fail('Could not find the GitHub login form on the authorize page. Page title: ' . $title . '. URL: ' . $authorizeUrl); - } - - $formParams = GitHubHtml::extractFormInputs($selector, $loginForm); - $formParams['login'] = $this->githubLogin; - $formParams['password'] = $this->githubPassword; - $loginActionUrl = GitHubHtml::resolveUrl($loginForm->getAttribute('action'), 'https://github.com/session'); - - $loginResult = $this->requestFollowingGitHubRedirects('POST', $loginActionUrl, [ - RequestOptions::FORM_PARAMS => $formParams, - ]); - - $loginResponse = $loginResult['response']; - $loginStatus = $loginResponse->getStatusCode(); - $loginBody = $loginResult['body']; - $finalUrl = $loginResult['final_url']; - - if (($loginResult['stopped_before_external_redirect'] ?? false) === true) { - return $this->interpretAuthenticatedResponse($loginBody, $finalUrl, 'login'); - } - - $this->assertOkStatus($loginStatus, 'GitHub login request failed after posting to ' . $loginActionUrl . '. Final URL: ' . $finalUrl . '.'); - - if (str_contains($loginBody, 'Incorrect username or password')) { - return [ - 'status' => 'invalid_credentials', - 'body' => $loginBody, - ]; - } - - return $this->interpretAuthenticatedResponse($loginBody, $finalUrl, 'login'); - } - - private function navigateToTotpPage(string $currentUrl, string $currentBody): array { - ['selector' => $selector] = $this->getPageContext($currentBody); - - $totpUrl = GitHubHtml::findTotpAlternativeUrl($selector); - if ($totpUrl === null) { - // Fallback: try the common GitHub TOTP URL directly - $totpUrl = 'https://github.com/sessions/two-factor/app'; - } - - $response = $this->client->get($totpUrl, [ - RequestOptions::HTTP_ERRORS => false, - ]); - $body = $response->getBody()->getContents(); - $statusCode = $response->getStatusCode(); - - $this->assertOkStatus( - $statusCode, - 'Failed to navigate to TOTP page from page without a recognized 2FA form. ' - . 'Source URL: ' . $currentUrl . '. Attempted TOTP URL: ' . $totpUrl . '. ' - . 'Source page: ' . GitHubHtml::describePage($selector) . '.' - ); - - return [ - 'url' => $totpUrl, - 'body' => $body, - ]; - } - - private function dismissTwoFactorCheckup(string $body, string $checkupUrl): array { - ['selector' => $selector] = $this->getPageContext($body); - $delayForm = GitHubHtml::findTwoFactorCheckupDelayForm($selector); - if ($delayForm === null) { - $this->fail('Expected a 2FA checkup delay form on ' . $checkupUrl . ' but none was found. Page: ' . GitHubHtml::describePage($selector)); - } - - $formParams = GitHubHtml::extractFormInputs($selector, $delayForm); - $actionUrl = GitHubHtml::resolveUrl($delayForm->getAttribute('action'), $checkupUrl); - - $result = $this->requestFollowingGitHubRedirects('POST', $actionUrl, [ - RequestOptions::FORM_PARAMS => $formParams, - ]); - $statusCode = $result['response']->getStatusCode(); - if (($result['stopped_before_external_redirect'] ?? false) !== true && $statusCode >= 400) { - $this->fail('Dismissing the 2FA checkup via ' . $actionUrl . ' returned status ' . $statusCode . '.'); - } - - return $this->interpretAuthenticatedResponse($result['body'], $result['final_url'], 'checkup dismissal'); - } - - private function handleTwoFactorPage(string $twoFactorUrl, string $body): array { - try { - $totpCodes = Totp::generateCandidates($this->githubTotpSecret); - } catch (\InvalidArgumentException $exception) { - $this->fail('CI_TOTP_SECRET is invalid: ' . $exception->getMessage()); - } - - $currentBody = $body; - $currentUrl = $twoFactorUrl; - $lastStatusCode = null; - $lastFinalUrl = $twoFactorUrl; - - foreach ($totpCodes as $totpCode) { - ['selector' => $selector, 'title' => $title] = $this->getPageContext($currentBody); - - $twoFactorForm = GitHubHtml::findTwoFactorForm($selector); - if ($twoFactorForm === null) { - // WebAuthn/passkey page may be shown instead of TOTP, navigate to TOTP alternative - $totpPage = $this->navigateToTotpPage($currentUrl, $currentBody); - $currentUrl = $totpPage['url']; - $currentBody = $totpPage['body']; - - ['selector' => $selector, 'title' => $title] = $this->getPageContext($currentBody); - $twoFactorForm = GitHubHtml::findTwoFactorForm($selector); - if ($twoFactorForm === null) { - $this->fail('Could not find the GitHub 2FA form after navigating away from WebAuthn page. Page title: ' . $title . '. URL: ' . $currentUrl); - } - } - - $formParams = GitHubHtml::extractFormInputs($selector, $twoFactorForm); - $twoFactorActionUrl = GitHubHtml::resolveUrl($twoFactorForm->getAttribute('action'), $currentUrl); - $attemptFormParams = $this->setOtpCode($formParams, $totpCode); - - $twoFactorResult = $this->requestFollowingGitHubRedirects('POST', $twoFactorActionUrl, [ - RequestOptions::FORM_PARAMS => $attemptFormParams, - ]); - - $twoFactorResponse = $twoFactorResult['response']; - $statusCode = $twoFactorResponse->getStatusCode(); - $responseBody = $twoFactorResult['body']; - $finalUrl = $twoFactorResult['final_url']; - $lastStatusCode = $statusCode; - $lastFinalUrl = $finalUrl; - - if (($twoFactorResult['stopped_before_external_redirect'] ?? false) === true) { - return $this->interpretAuthenticatedResponse($responseBody, $finalUrl, 'two-factor authentication'); - } - - $this->assertOkStatus($statusCode, 'GitHub 2FA request failed after posting to ' . $twoFactorActionUrl . '. Final URL: ' . $finalUrl . '.'); - - if (!$this->isInvalidTotpResponse($responseBody)) { - return $this->interpretAuthenticatedResponse($responseBody, $finalUrl, 'two-factor authentication'); - } - - $currentBody = $responseBody; - $currentUrl = $finalUrl; - } - - $this->fail('GitHub rejected all generated TOTP codes. Check CI_TOTP_SECRET or clock skew. Final URL: ' . $lastFinalUrl . '. Last status: ' . $lastStatusCode); - } - - private function handleAuthorizePage(string $authorizeUrl, string $body): string { - ['selector' => $selector, 'title' => $title] = $this->getPageContext($body); - - $authorizeForm = GitHubHtml::findAuthorizeForm($selector); - if ($authorizeForm === null) { - $this->fail('Could not find OAuth authorize form on page. The authorize page HTML may have changed or login did not succeed. Page title: ' . $title . '. URL: ' . $authorizeUrl); - } - - $formParams = GitHubHtml::extractFormInputs($selector, $authorizeForm); - $authorizeActionUrl = GitHubHtml::resolveUrl($authorizeForm->getAttribute('action'), $authorizeUrl); - - $authorizeResponse = $this->client->post($authorizeActionUrl, [ - RequestOptions::FORM_PARAMS => $formParams, - RequestOptions::ALLOW_REDIRECTS => false, - RequestOptions::HTTP_ERRORS => false, - ]); - - $statusCode = $authorizeResponse->getStatusCode(); - $responseBody = $authorizeResponse->getBody()->getContents(); - $location = $authorizeResponse->getHeaderLine('Location'); - if ($statusCode >= 300 && $statusCode < 400 && $location !== '') { - $redirectUrl = GitHubHtml::resolveUrl($location, $authorizeActionUrl); - if (str_contains($redirectUrl, 'code=')) { - return $redirectUrl; - } - - $this->fail('OAuth authorize form redirected without a code parameter. Redirect URL: ' . $redirectUrl); - } - - $this->fail('OAuth authorize form was submitted but GitHub did not return the expected redirect response. Status: ' . $statusCode . '. Location header: ' . ($location === '' ? 'empty' : $location)); - } - - private function extractCodeFromRedirectUrl(string $redirectUrl): string { - $query = parse_url($redirectUrl, PHP_URL_QUERY); - if ($query === null) { - $this->fail('Could not parse query string from redirect URL: ' . $redirectUrl); - } - - parse_str($query, $params); - if (!isset($params['code'])) { - $this->fail('No code parameter found in redirect URL: ' . $redirectUrl); - } - return $params['code']; - } - - /** - * @return array{userId: string, login: string} User ID and GitHub login for dependent tests - */ - public function testOAuthLogin(): array { - $this->requireCredentials(); - - $this->secretService->setEncryptedAppValue('client_id', $this->githubClientId); - $this->secretService->setEncryptedAppValue('client_secret', $this->githubClientSecret); - - $oauthState = $this->makeOAuthState(); - $this->config->setUserValue($this->userId, Application::APP_ID, 'oauth_state', $oauthState); - - $authorizeUrl = $this->getOAuthAuthorizeUrl($oauthState); - - $loginResult = $this->loginToGitHub($authorizeUrl); - - if ($loginResult['status'] === 'two_factor_required') { - $loginResult = $this->handleTwoFactorPage($loginResult['two_factor_url'] ?? $authorizeUrl, $loginResult['body']); - } - - if ($loginResult['status'] === 'two_factor_checkup') { - $loginResult = $this->dismissTwoFactorCheckup($loginResult['body'], $loginResult['checkup_url'] ?? $authorizeUrl); - } - - if ($loginResult['status'] === 'two_factor_checkup_blocked') { - $this->markTestSkipped( - 'GitHub is showing the two-factor authentication checkup page for the CI account at ' - . ($loginResult['checkup_url'] ?? $authorizeUrl) . ' and served no dismissable delay form. ' - . 'Sign in as the CI account once and complete or postpone the checkup to re-enable this test.' - ); - } - - if ($loginResult['status'] === 'invalid_credentials') { - $this->fail('Invalid GitHub credentials'); - } - - if ($loginResult['status'] === 'redirect_with_code') { - $redirectUrl = $loginResult['redirect_url']; - } elseif ($loginResult['status'] === 'authorize_page') { - $redirectUrl = $this->handleAuthorizePage($loginResult['authorize_url'] ?? $authorizeUrl, $loginResult['body']); - } else { - $this->fail('Unexpected login status: ' . $loginResult['status']); - } - - $code = $this->extractCodeFromRedirectUrl($redirectUrl); - - $parsedUrl = parse_url($redirectUrl); - $query = $parsedUrl['query'] ?? ''; - parse_str($query, $params); - $returnedState = $params['state'] ?? ''; - - $oauthRedirectResponse = $this->configController->oauthRedirect($code, $returnedState); - - $this->assertStringContainsString('githubToken=success', $oauthRedirectResponse->getRedirectURL(), - 'OAuth redirect did not return success'); - - $storedToken = $this->secretService->getEncryptedUserValue($this->userId, 'token'); - $this->assertNotSame('', $storedToken, 'Token was not stored'); - - $tokenType = $this->config->getUserValue($this->userId, Application::APP_ID, 'token_type'); - $this->assertSame('oauth', $tokenType, 'Token type should be oauth'); - - $userName = $this->config->getUserValue($this->userId, Application::APP_ID, 'user_name'); - $this->assertNotSame('', $userName, 'User name should be stored'); - - $userInfo = $this->githubAPIService->request($this->userId, 'user'); - $this->assertArrayNotHasKey('error', $userInfo, 'API request returned error: ' . json_encode($userInfo)); - $this->assertArrayHasKey('login', $userInfo); - $this->assertSame($this->githubLogin, $userInfo['login']); - - return [ - 'userId' => $this->userId, - 'login' => $this->githubLogin, - ]; + $this->configController = $this->createConfigController(); + } + + protected function tearDown(): void { + $this->resetTestUserConfig(); + $this->restoreOAuthApp(); + parent::tearDown(); + } + + public function testOAuthRedirectStoresTheTokenAndUserInfo(): void { + $this->config->setUserValue(self::TEST_USER_ID, Application::APP_ID, 'oauth_state', self::STATE); + + $this->client->expects($this->once()) + ->method('post') + ->with('https://github.com/login/oauth/access_token', [ + 'headers' => ['User-Agent' => self::USER_AGENT], + 'body' => [ + 'client_id' => self::CLIENT_ID, + 'client_secret' => self::CLIENT_SECRET, + 'code' => self::CODE, + 'state' => self::STATE, + ], + ]) + ->willReturn($this->mockResponse(200, 'access_token=' . self::ACCESS_TOKEN . '&scope=repo&token_type=bearer')); + $this->client->expects($this->once()) + ->method('get') + ->with('https://api.github.com/user', [ + 'timeout' => 30, + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => 'token ' . self::ACCESS_TOKEN, + ], + ]) + ->willReturn($this->mockResponse(200, json_encode(['login' => 'octocat', 'id' => 583231, 'name' => 'The Octocat']))); + + $response = $this->configController->oauthRedirect(self::CODE, self::STATE); + + $this->assertStringContainsString('githubToken=success', $response->getRedirectURL(), 'OAuth redirect did not return success'); + $this->assertSame(self::ACCESS_TOKEN, $this->secretService->getEncryptedUserValue(self::TEST_USER_ID, 'token'), 'Token was not stored'); + $this->assertSame('oauth', $this->userValue('token_type'), 'Token type should be oauth'); + $this->assertSame('583231', $this->userValue('user_id'), 'User id should be stored'); + $this->assertSame('octocat', $this->userValue('user_name'), 'User name should be stored'); + $this->assertSame('The Octocat', $this->userValue('user_displayname'), 'Display name should be stored'); + $this->assertSame('', $this->userValue('oauth_state'), 'The OAuth state should be consumed'); + } + + public function testOAuthRedirectRejectsAStateMismatch(): void { + $this->config->setUserValue(self::TEST_USER_ID, Application::APP_ID, 'oauth_state', self::STATE); + + $this->client->expects($this->never())->method('post'); + $this->client->expects($this->never())->method('get'); + + $response = $this->configController->oauthRedirect(self::CODE, 'another-state'); + + $this->assertStringContainsString('githubToken=error', $response->getRedirectURL(), 'A state mismatch should end in an error'); + $this->assertSame('', $this->secretService->getEncryptedUserValue(self::TEST_USER_ID, 'token'), 'No token should be stored'); + $this->assertSame('', $this->userValue('oauth_state'), 'The OAuth state should be reset'); + } + + private function userValue(string $key): string { + return $this->config->getUserValue(self::TEST_USER_ID, Application::APP_ID, $key); } } diff --git a/tests/integration/GithubTokenRevocationIntegrationTest.php b/tests/integration/GithubTokenRevocationIntegrationTest.php new file mode 100644 index 00000000..0c4db67c --- /dev/null +++ b/tests/integration/GithubTokenRevocationIntegrationTest.php @@ -0,0 +1,94 @@ +useTestUser(); + $this->useOAuthApp(self::CLIENT_ID, self::CLIENT_SECRET); + + $this->config = Server::get(IConfig::class); + $this->secretService = Server::get(SecretService::class); + $this->configController = $this->createConfigController(); + } + + protected function tearDown(): void { + $this->resetTestUserConfig(); + $this->restoreOAuthApp(); + parent::tearDown(); + } + + public function testDisconnectRevokesTheOAuthToken(): void { + $this->connect('oauth'); + + $this->client->expects($this->once()) + ->method('delete') + ->with('https://api.github.com/applications/' . self::CLIENT_ID . '/token', [ + 'headers' => [ + 'User-Agent' => self::USER_AGENT, + 'Authorization' => 'Basic ' . base64_encode(self::CLIENT_ID . ':' . self::CLIENT_SECRET), + ], + 'body' => json_encode(['access_token' => self::ACCESS_TOKEN]), + ]) + ->willReturn($this->mockResponse(204, '')); + + $response = $this->configController->setConfig(['token' => '']); + + $this->assertSame('', $response->getData()['user_name'], 'user_name should be empty after token revocation'); + $this->assertDisconnected(); + } + + public function testDisconnectingAPersonalTokenDoesNotCallGitHub(): void { + $this->connect('personal'); + + $this->client->expects($this->never())->method('delete'); + + $response = $this->configController->setConfig(['token' => '']); + + $this->assertSame('', $response->getData()['user_name'], 'user_name should be empty after disconnecting'); + $this->assertDisconnected(); + } + + private function connect(string $tokenType): void { + $this->secretService->setEncryptedUserValue(self::TEST_USER_ID, 'token', self::ACCESS_TOKEN); + $this->config->setUserValue(self::TEST_USER_ID, Application::APP_ID, 'token_type', $tokenType); + $this->config->setUserValue(self::TEST_USER_ID, Application::APP_ID, 'user_name', 'octocat'); + } + + private function assertDisconnected(): void { + $this->assertSame('', $this->secretService->getEncryptedUserValue(self::TEST_USER_ID, 'token'), 'Token should be removed'); + foreach (['token_type', 'user_name'] as $key) { + $this->assertSame('', $this->config->getUserValue(self::TEST_USER_ID, Application::APP_ID, $key), $key . ' should be removed'); + } + } +} diff --git a/tests/integration/GithubZTokenRevocationIntegrationTest.php b/tests/integration/GithubZTokenRevocationIntegrationTest.php deleted file mode 100644 index 41687633..00000000 --- a/tests/integration/GithubZTokenRevocationIntegrationTest.php +++ /dev/null @@ -1,50 +0,0 @@ -secretService = Server::get(SecretService::class); - $this->configController = Server::get(ConfigController::class); - } - - #[DependsExternal(GithubOauthIntegrationTest::class, 'testOAuthLogin')] - public function testRevokeToken(array $oauthData): void { - $this->assertIsArray($oauthData, 'oauthData should be an array from OAuth test'); - $this->assertArrayHasKey('userId', $oauthData, 'oauthData must contain userId'); - $userId = $oauthData['userId']; - - self::loginAsUser($userId); - - $token = $this->secretService->getEncryptedUserValue($userId, 'token'); - $this->assertNotSame('', $token, 'Token should exist before revocation'); - - $response = $this->configController->setConfig(['token' => '']); - - $this->assertArrayHasKey('user_name', $response->getData(), 'Response should contain user_name'); - $this->assertSame('', $response->getData()['user_name'], 'user_name should be empty after token revocation'); - - $tokenAfter = $this->secretService->getEncryptedUserValue($userId, 'token'); - $this->assertSame('', $tokenAfter, 'Token should be empty after revocation'); - } -} diff --git a/tests/integration/MockedGithubApiTrait.php b/tests/integration/MockedGithubApiTrait.php new file mode 100644 index 00000000..c86f5604 --- /dev/null +++ b/tests/integration/MockedGithubApiTrait.php @@ -0,0 +1,146 @@ + */ + private array $previousAppValues = []; + + private function useTestUser(): void { + $userManager = Server::get(IUserManager::class); + $user = $userManager->get(self::TEST_USER_ID) + ?? $userManager->createUser(self::TEST_USER_ID, 'test-password'); + self::loginAsUser($user->getUID()); + $this->resetTestUserConfig(); + } + + private function resetTestUserConfig(): void { + $config = Server::get(IConfig::class); + foreach (self::USER_CONFIG_KEYS as $key) { + $config->deleteUserValue(self::TEST_USER_ID, Application::APP_ID, $key); + } + } + + private function createGithubAPIService(): GithubAPIService { + $this->client = $this->createMock(IClient::class); + $clientService = $this->createMock(IClientService::class); + $clientService->method('newClient')->willReturn($this->client); + + $this->l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnArgument(0); + + return new GithubAPIService( + Server::get(SecretService::class), + Server::get(LoggerInterface::class), + $this->l10n, + Server::get(IConfig::class), + Server::get(IURLGenerator::class), + Server::get(IUserManager::class), + Server::get(INotificationManager::class), + $clientService, + ); + } + + private function createConfigController(): ConfigController { + $githubAPIService = $this->createGithubAPIService(); + + return new ConfigController( + Application::APP_ID, + $this->createMock(IRequest::class), + Server::get(IConfig::class), + Server::get(IURLGenerator::class), + $this->l10n, + $this->createMock(IInitialState::class), + Server::get(LoggerInterface::class), + $githubAPIService, + Server::get(SecretService::class), + Server::get(GithubIssuePrReferenceProvider::class), + self::TEST_USER_ID, + ); + } + + private function mockResponse(int $statusCode, string $body): IResponse&MockObject { + $response = $this->createMock(IResponse::class); + $response->method('getStatusCode')->willReturn($statusCode); + $response->method('getBody')->willReturn($body); + return $response; + } + + /** + * Stores OAuth app credentials for the test and keeps the previous values, + * which restoreOAuthApp() puts back. + */ + private function useOAuthApp(string $clientId, string $clientSecret): void { + $secretService = Server::get(SecretService::class); + $config = Server::get(IConfig::class); + $this->previousAppValues = [ + 'client_id' => $secretService->getEncryptedAppValue('client_id'), + 'client_secret' => $secretService->getEncryptedAppValue('client_secret'), + 'use_popup' => $config->getAppValue(Application::APP_ID, 'use_popup'), + ]; + + $secretService->setEncryptedAppValue('client_id', $clientId); + $secretService->setEncryptedAppValue('client_secret', $clientSecret); + $config->setAppValue(Application::APP_ID, 'use_popup', '0'); + } + + private function restoreOAuthApp(): void { + $secretService = Server::get(SecretService::class); + $config = Server::get(IConfig::class); + foreach ($this->previousAppValues as $key => $value) { + if ($value === '') { + $config->deleteAppValue(Application::APP_ID, $key); + } elseif ($key === 'use_popup') { + $config->setAppValue(Application::APP_ID, $key, $value); + } else { + $secretService->setEncryptedAppValue($key, $value); + } + } + $this->previousAppValues = []; + } +} diff --git a/tests/integration/Totp.php b/tests/integration/Totp.php deleted file mode 100644 index 231fec13..00000000 --- a/tests/integration/Totp.php +++ /dev/null @@ -1,67 +0,0 @@ -> 32) & 0xFFFFFFFF, $counter & 0xFFFFFFFF); - $hash = hash_hmac('sha1', $binaryCounter, $decodedSecret, true); - $offset = ord(substr($hash, -1)) & 0x0F; - $truncatedHash = unpack('N', substr($hash, $offset, 4))[1] & 0x7FFFFFFF; - - return str_pad((string)($truncatedHash % 1000000), 6, '0', STR_PAD_LEFT); - } - - public static function generateCandidates(string $secret): array { - $timestamp = time(); - - return array_values(array_unique([ - self::generateAt($secret, $timestamp), - self::generateAt($secret, $timestamp - 30), - self::generateAt($secret, $timestamp + 30), - ])); - } - - private static function decodeBase32Secret(string $secret): string { - $normalizedSecret = strtoupper(str_replace([' ', '-'], '', $secret)); - $buffer = 0; - $bitsLeft = 0; - $result = ''; - - foreach (str_split($normalizedSecret) as $char) { - if ($char === '=') { - continue; - } - $position = strpos(self::BASE32_ALPHABET, $char); - if ($position === false) { - throw new \InvalidArgumentException('The TOTP secret is not a valid base32 string'); - } - - $buffer = ($buffer << 5) | $position; - $bitsLeft += 5; - - while ($bitsLeft >= 8) { - $bitsLeft -= 8; - $result .= chr(($buffer >> $bitsLeft) & 0xFF); - } - } - - return $result; - } -} diff --git a/tests/integration/WorkflowTokenTrait.php b/tests/integration/WorkflowTokenTrait.php index 23a192f4..f8ac611a 100644 --- a/tests/integration/WorkflowTokenTrait.php +++ b/tests/integration/WorkflowTokenTrait.php @@ -20,10 +20,9 @@ * every workflow run, stored as the test user's personal token, instead of a token * obtained by logging in to a GitHub account. * - * It is the same Nextcloud user as in GithubOauthIntegrationTest, because the reference - * providers get their user injected when they are constructed. The user's previous token - * is restored afterwards, so the tests that depend on the OAuth flow keep the token that - * flow stored, whatever order the tests run in. + * It is the same Nextcloud user as in MockedGithubApiTrait, because the reference providers + * get their user injected when they are constructed. The user's previous token is restored + * afterwards, so each test leaves the user as it found it, whatever order the tests run in. */ trait WorkflowTokenTrait { private const WORKFLOW_TOKEN_USER_ID = 'github_test_user';