From 887932b1c700710fd2aae977dcea87a5f46ac478 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:48:48 +0000 Subject: [PATCH 1/5] Initial plan From 41dcb930d2e5775a90ebdddabed58af6028538c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:55:59 +0000 Subject: [PATCH 2/5] Implement GitHub webhook action for automatic version updates Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- components/GitHubWebhookAction.php | 231 ++++++++++++++++++ config/params.php | 4 + controllers/SiteController.php | 4 + .../components/GitHubWebhookActionTest.php | 71 ++++++ 4 files changed, 310 insertions(+) create mode 100644 components/GitHubWebhookAction.php create mode 100644 tests/unit/components/GitHubWebhookActionTest.php diff --git a/components/GitHubWebhookAction.php b/components/GitHubWebhookAction.php new file mode 100644 index 00000000..f4b91786 --- /dev/null +++ b/components/GitHubWebhookAction.php @@ -0,0 +1,231 @@ + [ + * 'class' => 'app\components\GitHubWebhookAction', + * 'versionsFile' => '@app/config/versions.php', + * ] + * ]; + * } + * + * GitHub webhook should be configured with: + * - Payload URL: https://www.yiiframework.com/site/github-webhook + * - Content type: application/json + * - Secret: configure in config/params.php as "github-webhook-secret" + * - Events: Release + */ +class GitHubWebhookAction extends Action +{ + /** + * @var string Path to the versions.php file + */ + public $versionsFile = '@app/config/versions.php'; + + /** + * Handles the GitHub webhook request + * + * @return string + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + * @throws MethodNotAllowedHttpException + */ + public function run() + { + if (!Yii::$app->request->isPost) { + Yii::$app->response->getHeaders()->set('Allow', 'POST'); + throw new MethodNotAllowedHttpException('Only POST requests are allowed'); + } + + $event = Yii::$app->request->headers->get('x-github-event'); + + // Handle ping event + if ($event === 'ping') { + return 'pong'; + } + + // Only process release events + if ($event !== 'release') { + return 'Event not handled'; + } + + $this->validateSignature(); + $this->processReleaseEvent(); + + return 'Version updated successfully'; + } + + /** + * Validates the GitHub webhook signature + * + * @throws BadRequestHttpException + * @throws ForbiddenHttpException + */ + protected function validateSignature() + { + $secret = Yii::$app->params['github-webhook-secret'] ?? null; + + if (empty($secret)) { + // If no secret is configured, skip validation (not recommended for production) + return; + } + + $signature = Yii::$app->request->headers->get('x-hub-signature-256'); + + if (empty($signature)) { + throw new ForbiddenHttpException('Missing signature header'); + } + + $payload = file_get_contents('php://input'); + $expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + if (!hash_equals($expectedSignature, $signature)) { + throw new ForbiddenHttpException('Invalid signature'); + } + } + + /** + * Processes the release event and updates version information + * + * @throws BadRequestHttpException + * @throws Exception + */ + protected function processReleaseEvent() + { + // Parse JSON payload + Yii::$app->request->parsers = [ + 'application/json' => 'yii\web\JsonParser', + ]; + + $payload = Yii::$app->request->post(); + + if (!isset($payload['action']) || !isset($payload['release'])) { + throw new BadRequestHttpException('Invalid payload format'); + } + + // Only process published releases + if ($payload['action'] !== 'published') { + return; + } + + $release = $payload['release']; + $repository = $payload['repository'] ?? []; + + if (!isset($release['tag_name']) || !isset($repository['full_name'])) { + throw new BadRequestHttpException('Missing required release information'); + } + + $this->updateVersionsFile($release, $repository); + } + + /** + * Updates the versions.php file with new release information + * + * @param array $release Release information from GitHub + * @param array $repository Repository information from GitHub + * @throws Exception + */ + protected function updateVersionsFile($release, $repository) + { + $versionsPath = Yii::getAlias($this->versionsFile); + + if (!is_file($versionsPath)) { + throw new Exception('Versions file not found: ' . $versionsPath); + } + + if (!is_writable($versionsPath)) { + throw new Exception('Versions file is not writable: ' . $versionsPath); + } + + $tagName = $release['tag_name']; + $publishedAt = $release['published_at']; + $repoName = $repository['full_name']; + + // Determine which version series this belongs to + $versionSeries = $this->getVersionSeries($tagName, $repoName); + + if ($versionSeries === null) { + // Not a supported repository or version format + return; + } + + // Format the date + $date = date('M j, Y', strtotime($publishedAt)); + + // Read current versions file + $content = file_get_contents($versionsPath); + + // Add the new version to the appropriate series + $pattern = '/(\'' . preg_quote($versionSeries, '/') . '\'\s*=>\s*\[)(.*?)(\],)/s'; + + if (preg_match($pattern, $content, $matches)) { + $beforeArray = $matches[1]; + $arrayContent = $matches[2]; + $afterArray = $matches[3]; + + // Add new version at the beginning of the array + $newVersionLine = "\n '$tagName' => '$date',"; + $newArrayContent = $newVersionLine . $arrayContent; + + $newContent = str_replace( + $beforeArray . $arrayContent . $afterArray, + $beforeArray . $newArrayContent . $afterArray, + $content + ); + + file_put_contents($versionsPath, $newContent); + + Yii::info("Updated $versionSeries with version $tagName", __METHOD__); + } + } + + /** + * Determines the version series based on tag name and repository + * + * @param string $tagName + * @param string $repoName + * @return string|null + */ + protected function getVersionSeries($tagName, $repoName) + { + // Map repositories to version series + $repoMapping = [ + 'yiisoft/yii2' => '2.0', + 'yiisoft/yii' => '1.1', // Yii 1.1 releases + ]; + + // Get base version series from repository + $baseSeries = $repoMapping[$repoName] ?? null; + + if ($baseSeries === null) { + return null; + } + + // For Yii 2.0, ensure the tag matches 2.x.x pattern + if ($baseSeries === '2.0' && !preg_match('/^2\.\d+\.\d+/', $tagName)) { + return null; + } + + // For Yii 1.1, ensure the tag matches 1.1.x pattern + if ($baseSeries === '1.1' && !preg_match('/^1\.1\.\d+/', $tagName)) { + return null; + } + + return $baseSeries; + } +} \ No newline at end of file diff --git a/config/params.php b/config/params.php index ac8011ef..88a8dc27 100644 --- a/config/params.php +++ b/config/params.php @@ -214,4 +214,8 @@ 'discourse.sso_secret' => '', 'discourse.sso_url' => 'https://forum.yiiframework.com', 'slack.invite.link' => 'https://join.slack.com/t/yii/shared_invite/enQtMzQ4MDExMDcyNTk2LTc0NDQ2ZTZhNjkzZDgwYjE4YjZlNGQxZjFmZDBjZTU3NjViMDE4ZTMxNDRkZjVlNmM1ZTA1ODVmZGUwY2U3NDA', + + // GitHub webhook secret for automatic version updates + // Configure this in params-local.php or set via environment variable + 'github-webhook-secret' => null, ]; diff --git a/controllers/SiteController.php b/controllers/SiteController.php index 5288e83d..00d7cb18 100644 --- a/controllers/SiteController.php +++ b/controllers/SiteController.php @@ -32,6 +32,10 @@ public function actions() 'class' => \yii\captcha\CaptchaAction::class, 'fixedVerifyCode' => YII_ENV_TEST ? 'testme' : null, ], + 'github-webhook' => [ + 'class' => \app\components\GitHubWebhookAction::class, + 'versionsFile' => '@app/config/versions.php', + ], ]; } diff --git a/tests/unit/components/GitHubWebhookActionTest.php b/tests/unit/components/GitHubWebhookActionTest.php new file mode 100644 index 00000000..a4913375 --- /dev/null +++ b/tests/unit/components/GitHubWebhookActionTest.php @@ -0,0 +1,71 @@ +action = new GitHubWebhookAction('github-webhook', $controller); + $this->action->versionsFile = '@tests/_output/test-versions.php'; + } + + public function testGetVersionSeries() + { + $method = new \ReflectionMethod($this->action, 'getVersionSeries'); + $method->setAccessible(true); + + // Test Yii 2.0 versions + $this->assertEquals('2.0', $method->invoke($this->action, '2.0.50', 'yiisoft/yii2')); + $this->assertEquals('2.0', $method->invoke($this->action, '2.1.0', 'yiisoft/yii2')); + + // Test Yii 1.1 versions + $this->assertEquals('1.1', $method->invoke($this->action, '1.1.30', 'yiisoft/yii')); + + // Test invalid versions + $this->assertNull($method->invoke($this->action, '3.0.0', 'yiisoft/yii2')); + $this->assertNull($method->invoke($this->action, '2.0.50', 'unknown/repo')); + $this->assertNull($method->invoke($this->action, '1.0.12', 'yiisoft/yii')); + } + + public function testCreateVersionsFile() + { + $versionsPath = Yii::getAlias($this->action->versionsFile); + + // Create a test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + '2.0.48' => 'May 22, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + '1.1.27' => 'November 21, 2022', + ], + ], +]; +PHP; + + @mkdir(dirname($versionsPath), 0777, true); + file_put_contents($versionsPath, $testVersionsContent); + + $this->assertFileExists($versionsPath); + + // Clean up + @unlink($versionsPath); + } +} \ No newline at end of file From 82aee0c262f6f38b7b479a645b8c4a5770caaf7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Jul 2025 10:58:35 +0000 Subject: [PATCH 3/5] Add documentation and improve webhook action robustness Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- components/GitHubWebhookAction.php | 18 ++++++-- docs/webhook-setup.md | 69 ++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 docs/webhook-setup.md diff --git a/components/GitHubWebhookAction.php b/components/GitHubWebhookAction.php index f4b91786..f6561d1f 100644 --- a/components/GitHubWebhookAction.php +++ b/components/GitHubWebhookAction.php @@ -160,7 +160,7 @@ protected function updateVersionsFile($release, $repository) $versionSeries = $this->getVersionSeries($tagName, $repoName); if ($versionSeries === null) { - // Not a supported repository or version format + Yii::info("Skipping release $tagName from $repoName - not a supported version", __METHOD__); return; } @@ -170,6 +170,12 @@ protected function updateVersionsFile($release, $repository) // Read current versions file $content = file_get_contents($versionsPath); + // Check if version already exists + if (strpos($content, "'$tagName'") !== false) { + Yii::info("Version $tagName already exists in versions file", __METHOD__); + return; + } + // Add the new version to the appropriate series $pattern = '/(\'' . preg_quote($versionSeries, '/') . '\'\s*=>\s*\[)(.*?)(\],)/s'; @@ -188,9 +194,13 @@ protected function updateVersionsFile($release, $repository) $content ); - file_put_contents($versionsPath, $newContent); - - Yii::info("Updated $versionSeries with version $tagName", __METHOD__); + if (file_put_contents($versionsPath, $newContent) !== false) { + Yii::info("Successfully updated $versionSeries with version $tagName", __METHOD__); + } else { + throw new Exception("Failed to write updated versions file"); + } + } else { + throw new Exception("Could not find version series '$versionSeries' in versions file"); } } diff --git a/docs/webhook-setup.md b/docs/webhook-setup.md new file mode 100644 index 00000000..d9b7f7b1 --- /dev/null +++ b/docs/webhook-setup.md @@ -0,0 +1,69 @@ +# GitHub Webhook for Automatic Version Updates + +This feature allows the website to automatically update framework version information when new releases are published on GitHub. + +## Setup + +### 1. Configure Webhook Secret + +Add the webhook secret to your configuration: + +```php +// config/params-local.php +return [ + // ... other params + 'github-webhook-secret' => 'your-secret-key-here', +]; +``` + +Or set via environment variable and reference it in params. + +### 2. Configure GitHub Webhooks + +For each repository you want to track (e.g., `yiisoft/yii2`, `yiisoft/yii`): + +1. Go to repository Settings → Webhooks +2. Click "Add webhook" +3. Configure: + - **Payload URL**: `https://www.yiiframework.com/site/github-webhook` + - **Content type**: `application/json` + - **Secret**: Use the same secret as configured above + - **Events**: Select "Releases" + - **Active**: ✓ Checked + +### 3. Supported Repositories + +Currently supported repositories: +- `yiisoft/yii2` → Updates `2.0` version series +- `yiisoft/yii` → Updates `1.1` version series + +### 4. How It Works + +When a new release is published: + +1. GitHub sends a webhook to `/site/github-webhook` +2. The webhook validates the signature for security +3. If the release is from a supported repository and matches the expected version format: + - The new version is added to the top of the appropriate array in `config/versions.php` + - The date is formatted as "Month Day, Year" + +### 5. Security + +- Webhook signatures are validated using HMAC-SHA256 +- Only "published" release events are processed +- Only releases from whitelisted repositories are accepted +- Only versions matching expected patterns are processed + +### 6. Testing + +To test the webhook: + +```bash +# Send a test payload (replace with actual secret) +curl -X POST https://www.yiiframework.com/site/github-webhook \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ping" \ + -d '{"zen": "Design for failure."}' +``` + +Expected response: "pong" \ No newline at end of file From f9461adc1cbf4daa804b8f3f4455576b0b6a3fd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Jul 2025 11:45:40 +0000 Subject: [PATCH 4/5] Add comprehensive tests for GitHubWebhookAction functionality Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- components/GitHubWebhookAction.php | 8 +- .../components/GitHubWebhookActionTest.php | 597 +++++++++++++++++- 2 files changed, 596 insertions(+), 9 deletions(-) diff --git a/components/GitHubWebhookAction.php b/components/GitHubWebhookAction.php index f6561d1f..032c6fdf 100644 --- a/components/GitHubWebhookAction.php +++ b/components/GitHubWebhookAction.php @@ -73,10 +73,11 @@ public function run() /** * Validates the GitHub webhook signature * + * @param string|null $payload Optional payload for testing * @throws BadRequestHttpException * @throws ForbiddenHttpException */ - protected function validateSignature() + protected function validateSignature($payload = null) { $secret = Yii::$app->params['github-webhook-secret'] ?? null; @@ -91,7 +92,10 @@ protected function validateSignature() throw new ForbiddenHttpException('Missing signature header'); } - $payload = file_get_contents('php://input'); + if ($payload === null) { + $payload = file_get_contents('php://input'); + } + $expectedSignature = 'sha256=' . hash_hmac('sha256', $payload, $secret); if (!hash_equals($expectedSignature, $signature)) { diff --git a/tests/unit/components/GitHubWebhookActionTest.php b/tests/unit/components/GitHubWebhookActionTest.php index a4913375..784dd904 100644 --- a/tests/unit/components/GitHubWebhookActionTest.php +++ b/tests/unit/components/GitHubWebhookActionTest.php @@ -5,6 +5,9 @@ use app\components\GitHubWebhookAction; use Yii; use yii\web\Controller; +use yii\web\BadRequestHttpException; +use yii\web\ForbiddenHttpException; +use yii\web\MethodNotAllowedHttpException; class GitHubWebhookActionTest extends \Codeception\Test\Unit { @@ -13,11 +16,26 @@ class GitHubWebhookActionTest extends \Codeception\Test\Unit */ protected $action; + /** + * @var string + */ + protected $testVersionsPath; + protected function _before() { $controller = new Controller('test', Yii::$app); $this->action = new GitHubWebhookAction('github-webhook', $controller); $this->action->versionsFile = '@tests/_output/test-versions.php'; + $this->testVersionsPath = Yii::getAlias($this->action->versionsFile); + + // Ensure output directory exists + @mkdir(dirname($this->testVersionsPath), 0777, true); + } + + protected function _after() + { + // Clean up test files + @unlink($this->testVersionsPath); } public function testGetVersionSeries() @@ -40,8 +58,6 @@ public function testGetVersionSeries() public function testCreateVersionsFile() { - $versionsPath = Yii::getAlias($this->action->versionsFile); - // Create a test versions file $testVersionsContent = <<<'PHP' testVersionsPath, $testVersionsContent); + $this->assertFileExists($this->testVersionsPath); + } + + public function testValidateSignatureWithValidSecret() + { + // Mock request with valid signature + $payload = '{"test": "data"}'; + $secret = 'test-secret'; + $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + // Set up Yii app params + Yii::$app->params['github-webhook-secret'] = $secret; + + // Mock request headers + Yii::$app->request->headers->set('x-hub-signature-256', $signature); + + $method = new \ReflectionMethod($this->action, 'validateSignature'); + $method->setAccessible(true); + + // Should not throw exception with valid signature + $method->invoke($this->action, $payload); + $this->assertTrue(true); // If we get here, validation passed + } + + public function testValidateSignatureWithInvalidSecret() + { + $payload = '{"test": "data"}'; + $secret = 'test-secret'; + $wrongSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret'); + + Yii::$app->params['github-webhook-secret'] = $secret; + Yii::$app->request->headers->set('x-hub-signature-256', $wrongSignature); + + $method = new \ReflectionMethod($this->action, 'validateSignature'); + $method->setAccessible(true); + + $this->expectException(ForbiddenHttpException::class); + $this->expectExceptionMessage('Invalid signature'); + $method->invoke($this->action, $payload); + } + + public function testValidateSignatureWithMissingHeader() + { + Yii::$app->params['github-webhook-secret'] = 'test-secret'; + Yii::$app->request->headers->remove('x-hub-signature-256'); + + $method = new \ReflectionMethod($this->action, 'validateSignature'); + $method->setAccessible(true); + + $this->expectException(ForbiddenHttpException::class); + $this->expectExceptionMessage('Missing signature header'); + $method->invoke($this->action); + } + + public function testValidateSignatureWithNoSecret() + { + // When no secret is configured, validation should be skipped + unset(Yii::$app->params['github-webhook-secret']); + + $method = new \ReflectionMethod($this->action, 'validateSignature'); + $method->setAccessible(true); + + // Should not throw exception when no secret is configured + $method->invoke($this->action); + $this->assertTrue(true); + } + + public function testRunWithNonPostRequest() + { + // Mock non-POST request + $_SERVER['REQUEST_METHOD'] = 'GET'; + Yii::$app->request->setIsPost(false); + + $this->expectException(MethodNotAllowedHttpException::class); + $this->expectExceptionMessage('Only POST requests are allowed'); + $this->action->run(); + } + + public function testRunWithPingEvent() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + Yii::$app->request->setIsPost(true); + Yii::$app->request->headers->set('x-github-event', 'ping'); + + $result = $this->action->run(); + $this->assertEquals('pong', $result); + } + + public function testRunWithNonReleaseEvent() + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + Yii::$app->request->setIsPost(true); + Yii::$app->request->headers->set('x-github-event', 'push'); + + $result = $this->action->run(); + $this->assertEquals('Event not handled', $result); + } + + public function testUpdateVersionsFileWithNewYii2Release() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + '2.0.48' => 'May 22, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + '1.1.27' => 'November 21, 2022', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + + // Mock release data + $release = [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + $method->invoke($this->action, $release, $repository); + + // Verify the file was updated + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertStringContains("'2.0.50' => 'Jan 15, 2024',", $updatedContent); + + // Verify the new version was added at the top + $this->assertStringContains("'2.0.50' => 'Jan 15, 2024',\n '2.0.49' => 'Aug 30, 2023',", $updatedContent); + } + + public function testUpdateVersionsFileWithNewYii11Release() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + '2.0.48' => 'May 22, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + '1.1.27' => 'November 21, 2022', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + + // Mock release data + $release = [ + 'tag_name' => '1.1.29', + 'published_at' => '2024-02-20T14:15:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + $method->invoke($this->action, $release, $repository); + + // Verify the file was updated + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertStringContains("'1.1.29' => 'Feb 20, 2024',", $updatedContent); + + // Verify the new version was added at the top of 1.1 series + $this->assertStringContains("'1.1.29' => 'Feb 20, 2024',\n '1.1.28' => 'February 28, 2023',", $updatedContent); + } + + public function testUpdateVersionsFileWithDuplicateVersion() + { + // Create test versions file with existing version + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.50' => 'Jan 15, 2024', + '2.0.49' => 'Aug 30, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + $originalContent = file_get_contents($this->testVersionsPath); + + // Try to add duplicate version + $release = [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + $method->invoke($this->action, $release, $repository); + + // Verify the file was not changed + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertEquals($originalContent, $updatedContent); + } + + public function testUpdateVersionsFileWithUnsupportedRepository() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + $originalContent = file_get_contents($this->testVersionsPath); + + // Try to add version from unsupported repository + $release = [ + 'tag_name' => '3.0.0', + 'published_at' => '2024-01-15T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'some/other-repo' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + $method->invoke($this->action, $release, $repository); + + // Verify the file was not changed + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertEquals($originalContent, $updatedContent); + } + + public function testUpdateVersionsFileWithInvalidVersionFormat() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + $originalContent = file_get_contents($this->testVersionsPath); + + // Try to add invalid version format + $release = [ + 'tag_name' => '3.0.0', // Invalid for yii2 repo + 'published_at' => '2024-01-15T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + $method->invoke($this->action, $release, $repository); + + // Verify the file was not changed + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertEquals($originalContent, $updatedContent); + } + + public function testProcessReleaseEventWithValidPayload() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + + // Mock valid release payload + $payload = [ + 'action' => 'published', + 'release' => [ + 'tag_name' => '2.0.51', + 'published_at' => '2024-03-01T12:00:00Z' + ], + 'repository' => [ + 'full_name' => 'yiisoft/yii2' + ] + ]; + + // Mock request post data + $_POST = $payload; + Yii::$app->request->setBodyParams($payload); + + $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); + $method->setAccessible(true); + $method->invoke($this->action); + + // Verify the file was updated + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertStringContains("'2.0.51' => 'Mar 1, 2024',", $updatedContent); + } + + public function testProcessReleaseEventWithNonPublishedAction() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + $originalContent = file_get_contents($this->testVersionsPath); + + // Mock release payload with non-published action + $payload = [ + 'action' => 'created', // Not 'published' + 'release' => [ + 'tag_name' => '2.0.51', + 'published_at' => '2024-03-01T12:00:00Z' + ], + 'repository' => [ + 'full_name' => 'yiisoft/yii2' + ] + ]; + + $_POST = $payload; + Yii::$app->request->setBodyParams($payload); + + $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); + $method->setAccessible(true); + $method->invoke($this->action); + + // Verify the file was not changed + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertEquals($originalContent, $updatedContent); + } + + public function testProcessReleaseEventWithMissingPayloadData() + { + // Mock invalid payload missing required fields + $payload = [ + 'action' => 'published' + // Missing 'release' and 'repository' + ]; + + $_POST = $payload; + Yii::$app->request->setBodyParams($payload); + + $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); + $method->setAccessible(true); + + $this->expectException(BadRequestHttpException::class); + $this->expectExceptionMessage('Invalid payload format'); + $method->invoke($this->action); + } + + public function testCompleteWorkflowWithValidReleaseWebhook() + { + // Create test versions file + $testVersionsContent = <<<'PHP' + [ + '2.0' => [ + '2.0.49' => 'Aug 30, 2023', + '2.0.48' => 'May 22, 2023', + ], + '1.1' => [ + '1.1.28' => 'February 28, 2023', + '1.1.27' => 'November 21, 2022', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + + // Mock complete webhook payload + $payload = json_encode([ + 'action' => 'published', + 'release' => [ + 'tag_name' => '2.0.54', + 'published_at' => '2024-07-29T10:30:00Z' + ], + 'repository' => [ + 'full_name' => 'yiisoft/yii2' + ] + ]); + + $secret = 'test-webhook-secret'; + $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + // Set up environment for full workflow test + $_SERVER['REQUEST_METHOD'] = 'POST'; + Yii::$app->params['github-webhook-secret'] = $secret; + Yii::$app->request->setIsPost(true); + Yii::$app->request->headers->set('x-github-event', 'release'); + Yii::$app->request->headers->set('x-hub-signature-256', $signature); + Yii::$app->request->setBodyParams(json_decode($payload, true)); + + // Override validateSignature to use our test payload + $reflection = new \ReflectionClass($this->action); + $validateMethod = $reflection->getMethod('validateSignature'); + $validateMethod->setAccessible(true); - $this->assertFileExists($versionsPath); + // Create a mock action that overrides validateSignature + $mockAction = new class('github-webhook', new Controller('test', Yii::$app)) extends GitHubWebhookAction { + public $testPayload; + + protected function validateSignature($payload = null) + { + return parent::validateSignature($this->testPayload); + } + }; + + $mockAction->versionsFile = $this->action->versionsFile; + $mockAction->testPayload = $payload; + + // Run the complete workflow + $result = $mockAction->run(); + + // Verify result + $this->assertEquals('Version updated successfully', $result); + + // Verify the file was updated correctly + $updatedContent = file_get_contents($this->testVersionsPath); + $this->assertStringContains("'2.0.54' => 'Jul 29, 2024',", $updatedContent); + + // Verify the new version was added at the top + $this->assertStringContains("'2.0.54' => 'Jul 29, 2024',\n '2.0.49' => 'Aug 30, 2023',", $updatedContent); + } + + public function testFileNotWritableError() + { + // Create test versions file and make it read-only + $testVersionsContent = <<<'PHP' + ['2.0' => []]]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + chmod($this->testVersionsPath, 0444); // Read-only + + $release = [ + 'tag_name' => '2.0.55', + 'published_at' => '2024-07-29T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + + $this->expectException(\yii\base\Exception::class); + $this->expectExceptionMessage('Versions file is not writable'); + $method->invoke($this->action, $release, $repository); + + // Restore permissions for cleanup + chmod($this->testVersionsPath, 0644); + } + + public function testFileNotFoundError() + { + // Don't create the versions file + $release = [ + 'tag_name' => '2.0.55', + 'published_at' => '2024-07-29T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); + + $this->expectException(\yii\base\Exception::class); + $this->expectExceptionMessage('Versions file not found'); + $method->invoke($this->action, $release, $repository); + } + + public function testVersionSeriesNotFoundError() + { + // Create test versions file without the expected version series + $testVersionsContent = <<<'PHP' + [ + '1.1' => [ + '1.1.28' => 'February 28, 2023', + ], + ], +]; +PHP; + + file_put_contents($this->testVersionsPath, $testVersionsContent); + + $release = [ + 'tag_name' => '2.0.55', + 'published_at' => '2024-07-29T10:30:00Z' + ]; + + $repository = [ + 'full_name' => 'yiisoft/yii2' + ]; + + $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); + $method->setAccessible(true); - // Clean up - @unlink($versionsPath); + $this->expectException(\yii\base\Exception::class); + $this->expectExceptionMessage("Could not find version series '2.0' in versions file"); + $method->invoke($this->action, $release, $repository); } } \ No newline at end of file From 14661a1d050a55fe633000346a7ce99e64f50176 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 29 Jul 2025 12:00:13 +0000 Subject: [PATCH 5/5] Move webhook docs to README and create functional tests with actual URL Co-authored-by: samdark <47294+samdark@users.noreply.github.com> --- README.md | 70 ++ docs/webhook-setup.md | 69 -- tests/functional/GitHubWebhookCest.php | 126 ++++ .../components/GitHubWebhookActionTest.php | 654 ------------------ 4 files changed, 196 insertions(+), 723 deletions(-) delete mode 100644 docs/webhook-setup.md create mode 100644 tests/functional/GitHubWebhookCest.php delete mode 100644 tests/unit/components/GitHubWebhookActionTest.php diff --git a/README.md b/README.md index 874c5ffe..38a20ceb 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,76 @@ The following commands need to be set up to run on a regular basis: Additionally, `queue/listen` should run as a daemon or `queue/run` as a cronjob. +### GitHub Webhook for Automatic Version Updates + +This feature allows the website to automatically update framework version information when new releases are published on GitHub. + +#### Setup + +##### 1. Configure Webhook Secret + +Add the webhook secret to your configuration: + +```php +// config/params-local.php +return [ + // ... other params + 'github-webhook-secret' => 'your-secret-key-here', +]; +``` + +Or set via environment variable and reference it in params. + +##### 2. Configure GitHub Webhooks + +For each repository you want to track (e.g., `yiisoft/yii2`, `yiisoft/yii`): + +1. Go to repository Settings → Webhooks +2. Click "Add webhook" +3. Configure: + - **Payload URL**: `https://www.yiiframework.com/site/github-webhook` + - **Content type**: `application/json` + - **Secret**: Use the same secret as configured above + - **Events**: Select "Releases" + - **Active**: ✓ Checked + +##### 3. Supported Repositories + +Currently supported repositories: +- `yiisoft/yii2` → Updates `2.0` version series +- `yiisoft/yii` → Updates `1.1` version series + +##### 4. How It Works + +When a new release is published: + +1. GitHub sends a webhook to `/site/github-webhook` +2. The webhook validates the signature for security +3. If the release is from a supported repository and matches the expected version format: + - The new version is added to the top of the appropriate array in `config/versions.php` + - The date is formatted as "Month Day, Year" + +##### 5. Security + +- Webhook signatures are validated using HMAC-SHA256 +- Only "published" release events are processed +- Only releases from whitelisted repositories are accepted +- Only versions matching expected patterns are processed + +##### 6. Testing + +To test the webhook: + +```bash +# Send a test payload (replace with actual secret) +curl -X POST https://www.yiiframework.com/site/github-webhook \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ping" \ + -d '{"zen": "Design for failure."}' +``` + +Expected response: "pong" + ### Deployment This section covers notes for deployment on a server, you may not need this for your dev env. OS is assumed to be Debian diff --git a/docs/webhook-setup.md b/docs/webhook-setup.md deleted file mode 100644 index d9b7f7b1..00000000 --- a/docs/webhook-setup.md +++ /dev/null @@ -1,69 +0,0 @@ -# GitHub Webhook for Automatic Version Updates - -This feature allows the website to automatically update framework version information when new releases are published on GitHub. - -## Setup - -### 1. Configure Webhook Secret - -Add the webhook secret to your configuration: - -```php -// config/params-local.php -return [ - // ... other params - 'github-webhook-secret' => 'your-secret-key-here', -]; -``` - -Or set via environment variable and reference it in params. - -### 2. Configure GitHub Webhooks - -For each repository you want to track (e.g., `yiisoft/yii2`, `yiisoft/yii`): - -1. Go to repository Settings → Webhooks -2. Click "Add webhook" -3. Configure: - - **Payload URL**: `https://www.yiiframework.com/site/github-webhook` - - **Content type**: `application/json` - - **Secret**: Use the same secret as configured above - - **Events**: Select "Releases" - - **Active**: ✓ Checked - -### 3. Supported Repositories - -Currently supported repositories: -- `yiisoft/yii2` → Updates `2.0` version series -- `yiisoft/yii` → Updates `1.1` version series - -### 4. How It Works - -When a new release is published: - -1. GitHub sends a webhook to `/site/github-webhook` -2. The webhook validates the signature for security -3. If the release is from a supported repository and matches the expected version format: - - The new version is added to the top of the appropriate array in `config/versions.php` - - The date is formatted as "Month Day, Year" - -### 5. Security - -- Webhook signatures are validated using HMAC-SHA256 -- Only "published" release events are processed -- Only releases from whitelisted repositories are accepted -- Only versions matching expected patterns are processed - -### 6. Testing - -To test the webhook: - -```bash -# Send a test payload (replace with actual secret) -curl -X POST https://www.yiiframework.com/site/github-webhook \ - -H "Content-Type: application/json" \ - -H "X-GitHub-Event: ping" \ - -d '{"zen": "Design for failure."}' -``` - -Expected response: "pong" \ No newline at end of file diff --git a/tests/functional/GitHubWebhookCest.php b/tests/functional/GitHubWebhookCest.php new file mode 100644 index 00000000..b406f71d --- /dev/null +++ b/tests/functional/GitHubWebhookCest.php @@ -0,0 +1,126 @@ +haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'ping'); + + $payload = json_encode(['zen' => 'Design for failure.']); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(200); + $I->seeResponseEquals('pong'); + } + + public function testMethodNotAllowed(\FunctionalTester $I) + { + $I->sendGET('/site/github-webhook'); + $I->seeResponseCodeIs(405); + } + + public function testInvalidSignatureWhenSecretSet(\FunctionalTester $I) + { + // Mock that we have a secret configured + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + $I->haveHttpHeader('X-Hub-Signature-256', 'sha256=invalid-signature'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + // Should either work (if no secret) or fail with 403 (if secret configured) + $I->seeResponseCodeIsInRange(200, 403); + } + + public function testUnsupportedRepository(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'some/other-repo'], + 'release' => [ + 'tag_name' => '1.0.0', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + $I->seeResponseContains('Unsupported repository'); + } + + public function testMissingRequiredFields(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + // Missing action field + $payload = json_encode([ + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + } + + public function testNonPublishedAction(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'created', // Not 'published' + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => '2.0.50', + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(200); + $I->seeResponseEquals('Ignored: not a published release'); + } + + public function testInvalidVersionFormat(\FunctionalTester $I) + { + $I->haveHttpHeader('Content-Type', 'application/json'); + $I->haveHttpHeader('X-GitHub-Event', 'release'); + + $payload = json_encode([ + 'action' => 'published', + 'repository' => ['full_name' => 'yiisoft/yii2'], + 'release' => [ + 'tag_name' => 'invalid-version', // Invalid format + 'published_at' => '2024-01-15T10:00:00Z' + ] + ]); + + $I->sendPOST('/site/github-webhook', $payload); + $I->seeResponseCodeIs(400); + $I->seeResponseContains('Invalid version format'); + } + + public function testEndpointIsAccessible(\FunctionalTester $I) + { + // Test that the endpoint exists and responds + $I->sendPOST('/site/github-webhook'); + // Should not be 404, meaning the route exists + $I->dontSeeResponseCodeIs(404); + } +} \ No newline at end of file diff --git a/tests/unit/components/GitHubWebhookActionTest.php b/tests/unit/components/GitHubWebhookActionTest.php deleted file mode 100644 index 784dd904..00000000 --- a/tests/unit/components/GitHubWebhookActionTest.php +++ /dev/null @@ -1,654 +0,0 @@ -action = new GitHubWebhookAction('github-webhook', $controller); - $this->action->versionsFile = '@tests/_output/test-versions.php'; - $this->testVersionsPath = Yii::getAlias($this->action->versionsFile); - - // Ensure output directory exists - @mkdir(dirname($this->testVersionsPath), 0777, true); - } - - protected function _after() - { - // Clean up test files - @unlink($this->testVersionsPath); - } - - public function testGetVersionSeries() - { - $method = new \ReflectionMethod($this->action, 'getVersionSeries'); - $method->setAccessible(true); - - // Test Yii 2.0 versions - $this->assertEquals('2.0', $method->invoke($this->action, '2.0.50', 'yiisoft/yii2')); - $this->assertEquals('2.0', $method->invoke($this->action, '2.1.0', 'yiisoft/yii2')); - - // Test Yii 1.1 versions - $this->assertEquals('1.1', $method->invoke($this->action, '1.1.30', 'yiisoft/yii')); - - // Test invalid versions - $this->assertNull($method->invoke($this->action, '3.0.0', 'yiisoft/yii2')); - $this->assertNull($method->invoke($this->action, '2.0.50', 'unknown/repo')); - $this->assertNull($method->invoke($this->action, '1.0.12', 'yiisoft/yii')); - } - - public function testCreateVersionsFile() - { - // Create a test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - '2.0.48' => 'May 22, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - '1.1.27' => 'November 21, 2022', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - $this->assertFileExists($this->testVersionsPath); - } - - public function testValidateSignatureWithValidSecret() - { - // Mock request with valid signature - $payload = '{"test": "data"}'; - $secret = 'test-secret'; - $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); - - // Set up Yii app params - Yii::$app->params['github-webhook-secret'] = $secret; - - // Mock request headers - Yii::$app->request->headers->set('x-hub-signature-256', $signature); - - $method = new \ReflectionMethod($this->action, 'validateSignature'); - $method->setAccessible(true); - - // Should not throw exception with valid signature - $method->invoke($this->action, $payload); - $this->assertTrue(true); // If we get here, validation passed - } - - public function testValidateSignatureWithInvalidSecret() - { - $payload = '{"test": "data"}'; - $secret = 'test-secret'; - $wrongSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret'); - - Yii::$app->params['github-webhook-secret'] = $secret; - Yii::$app->request->headers->set('x-hub-signature-256', $wrongSignature); - - $method = new \ReflectionMethod($this->action, 'validateSignature'); - $method->setAccessible(true); - - $this->expectException(ForbiddenHttpException::class); - $this->expectExceptionMessage('Invalid signature'); - $method->invoke($this->action, $payload); - } - - public function testValidateSignatureWithMissingHeader() - { - Yii::$app->params['github-webhook-secret'] = 'test-secret'; - Yii::$app->request->headers->remove('x-hub-signature-256'); - - $method = new \ReflectionMethod($this->action, 'validateSignature'); - $method->setAccessible(true); - - $this->expectException(ForbiddenHttpException::class); - $this->expectExceptionMessage('Missing signature header'); - $method->invoke($this->action); - } - - public function testValidateSignatureWithNoSecret() - { - // When no secret is configured, validation should be skipped - unset(Yii::$app->params['github-webhook-secret']); - - $method = new \ReflectionMethod($this->action, 'validateSignature'); - $method->setAccessible(true); - - // Should not throw exception when no secret is configured - $method->invoke($this->action); - $this->assertTrue(true); - } - - public function testRunWithNonPostRequest() - { - // Mock non-POST request - $_SERVER['REQUEST_METHOD'] = 'GET'; - Yii::$app->request->setIsPost(false); - - $this->expectException(MethodNotAllowedHttpException::class); - $this->expectExceptionMessage('Only POST requests are allowed'); - $this->action->run(); - } - - public function testRunWithPingEvent() - { - $_SERVER['REQUEST_METHOD'] = 'POST'; - Yii::$app->request->setIsPost(true); - Yii::$app->request->headers->set('x-github-event', 'ping'); - - $result = $this->action->run(); - $this->assertEquals('pong', $result); - } - - public function testRunWithNonReleaseEvent() - { - $_SERVER['REQUEST_METHOD'] = 'POST'; - Yii::$app->request->setIsPost(true); - Yii::$app->request->headers->set('x-github-event', 'push'); - - $result = $this->action->run(); - $this->assertEquals('Event not handled', $result); - } - - public function testUpdateVersionsFileWithNewYii2Release() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - '2.0.48' => 'May 22, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - '1.1.27' => 'November 21, 2022', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - - // Mock release data - $release = [ - 'tag_name' => '2.0.50', - 'published_at' => '2024-01-15T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - $method->invoke($this->action, $release, $repository); - - // Verify the file was updated - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertStringContains("'2.0.50' => 'Jan 15, 2024',", $updatedContent); - - // Verify the new version was added at the top - $this->assertStringContains("'2.0.50' => 'Jan 15, 2024',\n '2.0.49' => 'Aug 30, 2023',", $updatedContent); - } - - public function testUpdateVersionsFileWithNewYii11Release() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - '2.0.48' => 'May 22, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - '1.1.27' => 'November 21, 2022', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - - // Mock release data - $release = [ - 'tag_name' => '1.1.29', - 'published_at' => '2024-02-20T14:15:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - $method->invoke($this->action, $release, $repository); - - // Verify the file was updated - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertStringContains("'1.1.29' => 'Feb 20, 2024',", $updatedContent); - - // Verify the new version was added at the top of 1.1 series - $this->assertStringContains("'1.1.29' => 'Feb 20, 2024',\n '1.1.28' => 'February 28, 2023',", $updatedContent); - } - - public function testUpdateVersionsFileWithDuplicateVersion() - { - // Create test versions file with existing version - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.50' => 'Jan 15, 2024', - '2.0.49' => 'Aug 30, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - $originalContent = file_get_contents($this->testVersionsPath); - - // Try to add duplicate version - $release = [ - 'tag_name' => '2.0.50', - 'published_at' => '2024-01-15T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - $method->invoke($this->action, $release, $repository); - - // Verify the file was not changed - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertEquals($originalContent, $updatedContent); - } - - public function testUpdateVersionsFileWithUnsupportedRepository() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - $originalContent = file_get_contents($this->testVersionsPath); - - // Try to add version from unsupported repository - $release = [ - 'tag_name' => '3.0.0', - 'published_at' => '2024-01-15T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'some/other-repo' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - $method->invoke($this->action, $release, $repository); - - // Verify the file was not changed - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertEquals($originalContent, $updatedContent); - } - - public function testUpdateVersionsFileWithInvalidVersionFormat() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - $originalContent = file_get_contents($this->testVersionsPath); - - // Try to add invalid version format - $release = [ - 'tag_name' => '3.0.0', // Invalid for yii2 repo - 'published_at' => '2024-01-15T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - $method->invoke($this->action, $release, $repository); - - // Verify the file was not changed - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertEquals($originalContent, $updatedContent); - } - - public function testProcessReleaseEventWithValidPayload() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - - // Mock valid release payload - $payload = [ - 'action' => 'published', - 'release' => [ - 'tag_name' => '2.0.51', - 'published_at' => '2024-03-01T12:00:00Z' - ], - 'repository' => [ - 'full_name' => 'yiisoft/yii2' - ] - ]; - - // Mock request post data - $_POST = $payload; - Yii::$app->request->setBodyParams($payload); - - $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); - $method->setAccessible(true); - $method->invoke($this->action); - - // Verify the file was updated - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertStringContains("'2.0.51' => 'Mar 1, 2024',", $updatedContent); - } - - public function testProcessReleaseEventWithNonPublishedAction() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - $originalContent = file_get_contents($this->testVersionsPath); - - // Mock release payload with non-published action - $payload = [ - 'action' => 'created', // Not 'published' - 'release' => [ - 'tag_name' => '2.0.51', - 'published_at' => '2024-03-01T12:00:00Z' - ], - 'repository' => [ - 'full_name' => 'yiisoft/yii2' - ] - ]; - - $_POST = $payload; - Yii::$app->request->setBodyParams($payload); - - $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); - $method->setAccessible(true); - $method->invoke($this->action); - - // Verify the file was not changed - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertEquals($originalContent, $updatedContent); - } - - public function testProcessReleaseEventWithMissingPayloadData() - { - // Mock invalid payload missing required fields - $payload = [ - 'action' => 'published' - // Missing 'release' and 'repository' - ]; - - $_POST = $payload; - Yii::$app->request->setBodyParams($payload); - - $method = new \ReflectionMethod($this->action, 'processReleaseEvent'); - $method->setAccessible(true); - - $this->expectException(BadRequestHttpException::class); - $this->expectExceptionMessage('Invalid payload format'); - $method->invoke($this->action); - } - - public function testCompleteWorkflowWithValidReleaseWebhook() - { - // Create test versions file - $testVersionsContent = <<<'PHP' - [ - '2.0' => [ - '2.0.49' => 'Aug 30, 2023', - '2.0.48' => 'May 22, 2023', - ], - '1.1' => [ - '1.1.28' => 'February 28, 2023', - '1.1.27' => 'November 21, 2022', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - - // Mock complete webhook payload - $payload = json_encode([ - 'action' => 'published', - 'release' => [ - 'tag_name' => '2.0.54', - 'published_at' => '2024-07-29T10:30:00Z' - ], - 'repository' => [ - 'full_name' => 'yiisoft/yii2' - ] - ]); - - $secret = 'test-webhook-secret'; - $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); - - // Set up environment for full workflow test - $_SERVER['REQUEST_METHOD'] = 'POST'; - Yii::$app->params['github-webhook-secret'] = $secret; - Yii::$app->request->setIsPost(true); - Yii::$app->request->headers->set('x-github-event', 'release'); - Yii::$app->request->headers->set('x-hub-signature-256', $signature); - Yii::$app->request->setBodyParams(json_decode($payload, true)); - - // Override validateSignature to use our test payload - $reflection = new \ReflectionClass($this->action); - $validateMethod = $reflection->getMethod('validateSignature'); - $validateMethod->setAccessible(true); - - // Create a mock action that overrides validateSignature - $mockAction = new class('github-webhook', new Controller('test', Yii::$app)) extends GitHubWebhookAction { - public $testPayload; - - protected function validateSignature($payload = null) - { - return parent::validateSignature($this->testPayload); - } - }; - - $mockAction->versionsFile = $this->action->versionsFile; - $mockAction->testPayload = $payload; - - // Run the complete workflow - $result = $mockAction->run(); - - // Verify result - $this->assertEquals('Version updated successfully', $result); - - // Verify the file was updated correctly - $updatedContent = file_get_contents($this->testVersionsPath); - $this->assertStringContains("'2.0.54' => 'Jul 29, 2024',", $updatedContent); - - // Verify the new version was added at the top - $this->assertStringContains("'2.0.54' => 'Jul 29, 2024',\n '2.0.49' => 'Aug 30, 2023',", $updatedContent); - } - - public function testFileNotWritableError() - { - // Create test versions file and make it read-only - $testVersionsContent = <<<'PHP' - ['2.0' => []]]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - chmod($this->testVersionsPath, 0444); // Read-only - - $release = [ - 'tag_name' => '2.0.55', - 'published_at' => '2024-07-29T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - - $this->expectException(\yii\base\Exception::class); - $this->expectExceptionMessage('Versions file is not writable'); - $method->invoke($this->action, $release, $repository); - - // Restore permissions for cleanup - chmod($this->testVersionsPath, 0644); - } - - public function testFileNotFoundError() - { - // Don't create the versions file - $release = [ - 'tag_name' => '2.0.55', - 'published_at' => '2024-07-29T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - - $this->expectException(\yii\base\Exception::class); - $this->expectExceptionMessage('Versions file not found'); - $method->invoke($this->action, $release, $repository); - } - - public function testVersionSeriesNotFoundError() - { - // Create test versions file without the expected version series - $testVersionsContent = <<<'PHP' - [ - '1.1' => [ - '1.1.28' => 'February 28, 2023', - ], - ], -]; -PHP; - - file_put_contents($this->testVersionsPath, $testVersionsContent); - - $release = [ - 'tag_name' => '2.0.55', - 'published_at' => '2024-07-29T10:30:00Z' - ]; - - $repository = [ - 'full_name' => 'yiisoft/yii2' - ]; - - $method = new \ReflectionMethod($this->action, 'updateVersionsFile'); - $method->setAccessible(true); - - $this->expectException(\yii\base\Exception::class); - $this->expectExceptionMessage("Could not find version series '2.0' in versions file"); - $method->invoke($this->action, $release, $repository); - } -} \ No newline at end of file