diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6981a61..4912f27 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -37,7 +37,7 @@ jobs: matrix: php-versions: ['8.2'] databases: ['sqlite'] - server-versions: ['master'] + server-versions: ['master', 'stable33'] name: php${{ matrix.php-versions }}-${{ matrix.databases }}-${{ matrix.server-versions }} @@ -80,3 +80,11 @@ jobs: CI_USER_PASSWORD: ${{ secrets.CI_USER_PASSWORD }} CI_TOTP_SECRET: ${{ secrets.CI_TOTP_SECRET }} run: composer run test:integration + + - name: Upload Nextcloud log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: nextcloud-log-${{ matrix.server-versions }} + path: data/nextcloud.log + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ea4cd..00400f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## 3.2.3 - 2026-04-21 + +### Added + +- Added support for Nextcloud 34. + +### Changed + +- Updated dependencies & translations. + ## 3.2.2 - 2025-11-10 ### Added diff --git a/appinfo/info.xml b/appinfo/info.xml index 5def02a..0ea0b13 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ - 3.2.2 + 3.2.3 agpl Julien Veyssier Github diff --git a/composer.json b/composer.json index 6692413..26975c2 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ } ], "require": { - "league/commonmark": "^2.3", + "league/commonmark": "^2.8.2", "php": "^8.2", "bamarni/composer-bin-plugin": "^1.8" }, diff --git a/composer.lock b/composer.lock index 61f7458..e1af0a6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "80537f3ad881fb421b9530dab60ddcac", + "content-hash": "c7083f1461bd67ef44ce5c9f3c3baecf", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -140,16 +140,16 @@ }, { "name": "league/commonmark", - "version": "2.8.1", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "84b1ca48347efdbe775426f108622a42735a6579" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", - "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -243,7 +243,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T21:37:03+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -604,16 +604,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.36.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -664,7 +664,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0" }, "funding": [ { @@ -684,7 +684,7 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" } ], "packages-dev": [ @@ -905,5 +905,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/tests/integration/GitHubHtml.php b/tests/integration/GitHubHtml.php index c964f56..b046d18 100644 --- a/tests/integration/GitHubHtml.php +++ b/tests/integration/GitHubHtml.php @@ -57,6 +57,13 @@ public static function findTwoFactorForm(DOMXPath $selector): ?DOMElement { ]); } + 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")]', + ]); + } + public static function findTotpAlternativeUrl(DOMXPath $selector): ?string { $linkSelectors = [ '//a[contains(@href, "two-factor/app")]', @@ -106,4 +113,45 @@ public static function extractFormInputs(DOMXPath $selector, DOMElement $form): 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/GithubOauthIntegrationTest.php b/tests/integration/GithubOauthIntegrationTest.php index ba770fb..fdf55f6 100644 --- a/tests/integration/GithubOauthIntegrationTest.php +++ b/tests/integration/GithubOauthIntegrationTest.php @@ -238,6 +238,17 @@ private function interpretAuthenticatedResponse(string $body, string $finalUrl, ['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, + ]; + } + $isTwoFactorPage = GitHubHtml::findTwoFactorForm($selector) !== null || str_contains($finalUrl, 'two-factor') || str_contains($title, 'Two-factor authentication'); @@ -261,7 +272,11 @@ private function interpretAuthenticatedResponse(string $body, string $finalUrl, $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); + $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 { @@ -327,7 +342,12 @@ private function navigateToTotpPage(string $currentUrl, string $currentBody): ar $body = $response->getBody()->getContents(); $statusCode = $response->getStatusCode(); - $this->assertOkStatus($statusCode, 'Failed to navigate to TOTP page from WebAuthn page. URL: ' . $totpUrl . '.'); + $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, @@ -335,6 +355,27 @@ private function navigateToTotpPage(string $currentUrl, string $currentBody): ar ]; } + 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); @@ -461,6 +502,10 @@ public function testOAuthLogin(): array { $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'] === 'invalid_credentials') { $this->fail('Invalid GitHub credentials'); }