diff --git a/.vortex/tooling/src/download-db-acquia b/.vortex/tooling/src/download-db-acquia index 182208433..217438ae2 100644 --- a/.vortex/tooling/src/download-db-acquia +++ b/.vortex/tooling/src/download-db-acquia @@ -224,25 +224,35 @@ else { note(sprintf('Using the latest backup ID %s for DB %s.', $backup_id, $db_name)); task('Discovering backup URL.'); - $backup_url_response = request_get(sprintf('https://cloud.acquia.com/api/environments/%s/databases/%s/backups/%s/actions/download', $env_id, $db_name, $backup_id), dl_acquia_api_headers($token)); + // The Acquia API responds with a 302 redirect to the final S3 download + // URL. Capture the redirect URL without following it to avoid sending the + // Authorization header to S3 - the auth header is required only to query + // the Acquia API. + $backup_url_response = request(sprintf('https://cloud.acquia.com/api/environments/%s/databases/%s/backups/%s/actions/download', $env_id, $db_name, $backup_id), ['method' => 'GET', 'headers' => dl_acquia_api_headers($token), 'follow_redirects' => FALSE]); if (!$backup_url_response['ok']) { - fail('Failed to retrieve backup URL for backup ID \'%s\'.', $backup_id); + fail(sprintf( + 'Unable to discover backup URL for backup ID \'%s\' (status: %s, error: %s).', + $backup_id, + (string) ($backup_url_response['status'] ?? 'unknown'), + (string) ($backup_url_response['error'] ?? 'none') + )); } - $backup_url_data = json_decode((string) $backup_url_response['body'], TRUE); - $backup_url = $backup_url_data['url'] ?? ''; + $backup_url = (string) ($backup_url_response['info']['redirect_url'] ?? ''); if ($backup_url === '') { fail(sprintf('Unable to discover backup URL for backup ID \'%s\'.', $backup_id)); } task(sprintf('Downloading DB dump into file %s.', $file_name_compressed)); - $dl_response = request($backup_url, ['method' => 'GET', 'headers' => dl_acquia_api_headers($token), 'save_to' => $file_name_compressed, 'timeout' => 600]); + $dl_response = request($backup_url, ['method' => 'GET', 'save_to' => $file_name_compressed, 'timeout' => 600]); if (!$dl_response['ok']) { fail('Unable to download database %s.', $db_name); } + // Check if the downloaded file exists and has content. Leave the file in + // place on failure so it can be inspected. if (!file_exists($file_name_compressed) || filesize($file_name_compressed) === 0) { fail(sprintf('Downloaded file is empty or missing: %s', $file_name_compressed)); } @@ -253,15 +263,15 @@ else { task(sprintf('Expanding DB file %s into %s.', $file_name_compressed, $file_name)); + // Test the gzip file first to ensure it's valid. Leave the file in place + // on failure so it can be inspected. $header = file_get_contents($file_name_compressed, FALSE, NULL, 0, 2); if ($header === FALSE || $header !== "\x1f\x8b") { - @unlink($file_name_compressed); fail(sprintf('Downloaded file is not a valid gzip archive: %s', $file_name_compressed)); } $gz_in = @fopen('compress.zlib://' . $file_name_compressed, 'rb'); if ($gz_in === FALSE) { - @unlink($file_name_compressed); // @codeCoverageIgnoreStart fail(sprintf('Unable to read compressed file %s.', $file_name_compressed)); // @codeCoverageIgnoreEnd @@ -270,7 +280,6 @@ else { $out = @fopen($file_name, 'wb'); if ($out === FALSE) { fclose($gz_in); - @unlink($file_name_compressed); // @codeCoverageIgnoreStart fail(sprintf('Unable to write decompressed file %s.', $file_name)); // @codeCoverageIgnoreEnd @@ -280,17 +289,17 @@ else { fclose($gz_in); fclose($out); + // Check decompression result and file validity. Leave both files in place + // on failure so they can be inspected. if ($bytes === FALSE || $bytes === 0) { - @unlink($file_name_compressed); - @unlink($file_name); fail(sprintf('Downloaded file is not a valid gzip archive: %s', $file_name_compressed)); } - @unlink($file_name_compressed); - if (!file_exists($file_name) || filesize($file_name) === 0) { fail(sprintf('Unable to process DB dump file "%s".', $file_name)); } + + @unlink($file_name_compressed); } task(sprintf('Renaming file "%s" to "%s/%s".', $file_name, $db_dir, $db_file)); diff --git a/.vortex/tooling/src/helpers.php b/.vortex/tooling/src/helpers.php index ce3155e75..f751c11e2 100755 --- a/.vortex/tooling/src/helpers.php +++ b/.vortex/tooling/src/helpers.php @@ -546,7 +546,7 @@ function request_post(string $url, $body = NULL, array $headers = [], int $timeo * * @param string $url * URL to request. - * @param array{method?: string, headers?: array, body?: mixed, timeout?: int, save_to?: string, upload_file?: string, auth?: string} $options + * @param array{method?: string, headers?: array, body?: mixed, timeout?: int, save_to?: string, upload_file?: string, auth?: string, follow_redirects?: bool} $options * Array of options: * - method: HTTP method (GET, POST, PUT, etc.) * - headers: Array of HTTP headers @@ -554,7 +554,9 @@ function request_post(string $url, $body = NULL, array $headers = [], int $timeo * - timeout: Request timeout in seconds * - save_to: Path to save response body to file * - upload_file: Path to file to upload (sets CURLOPT_UPLOAD) - * - auth: 'user:pass' for CURLOPT_USERPWD authentication. + * - auth: 'user:pass' for CURLOPT_USERPWD authentication + * - follow_redirects: Whether to follow HTTP redirects (default: TRUE). + * When FALSE, the redirect target is exposed via info.redirect_url. * * @return array{ok: bool, status: int, body: string|false, error: string|null, info: array} * Array with keys: @@ -579,7 +581,7 @@ function request(string $url, array $options = []): array { /** @var array $opts */ $opts = [ CURLOPT_RETURNTRANSFER => TRUE, - CURLOPT_FOLLOWLOCATION => TRUE, + CURLOPT_FOLLOWLOCATION => $options['follow_redirects'] ?? TRUE, CURLOPT_TIMEOUT => $options['timeout'] ?? 10, ]; diff --git a/.vortex/tooling/src/notify-email b/.vortex/tooling/src/notify-email index 0c40284bd..7d908d9d2 100755 --- a/.vortex/tooling/src/notify-email +++ b/.vortex/tooling/src/notify-email @@ -13,6 +13,25 @@ namespace DrevOps\VortexTooling; require_once __DIR__ . '/helpers.php'; execute_override(basename(__FILE__)); +// ----------------------------------------------------------------------------- +// Branch filter gate. +// +// Comma-separated list of branch names. When set, email notifications are +// only sent for deployments on the listed branches. When empty, no filtering +// is applied. Checked before required variable validation so channels not +// configured for the current branch can short-circuit cleanly. +// ----------------------------------------------------------------------------- + +$notify_email_branches = getenv_default('VORTEX_NOTIFY_EMAIL_BRANCHES', ''); +if ($notify_email_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_email_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping email notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + // ----------------------------------------------------------------------------- // Email notification project name. diff --git a/.vortex/tooling/src/notify-github b/.vortex/tooling/src/notify-github index cc7b7e15d..7ceb9af6e 100755 --- a/.vortex/tooling/src/notify-github +++ b/.vortex/tooling/src/notify-github @@ -20,6 +20,25 @@ namespace DrevOps\VortexTooling; require_once __DIR__ . '/helpers.php'; execute_override(basename(__FILE__)); +// ----------------------------------------------------------------------------- +// Branch filter gate. +// +// Comma-separated list of branch names. When set, GitHub notifications are +// only sent for deployments on the listed branches. When empty, no filtering +// is applied. Checked before required variable validation so channels not +// configured for the current branch can short-circuit cleanly. +// ----------------------------------------------------------------------------- + +$notify_github_branches = getenv_default('VORTEX_NOTIFY_GITHUB_BRANCHES', ''); +if ($notify_github_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_github_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping GitHub notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + // ----------------------------------------------------------------------------- // GitHub notification personal access token. diff --git a/.vortex/tooling/src/notify-jira b/.vortex/tooling/src/notify-jira index c90568f69..f57626a29 100755 --- a/.vortex/tooling/src/notify-jira +++ b/.vortex/tooling/src/notify-jira @@ -20,6 +20,25 @@ namespace DrevOps\VortexTooling; require_once __DIR__ . '/helpers.php'; execute_override(basename(__FILE__)); +// ----------------------------------------------------------------------------- +// Branch filter gate. +// +// Comma-separated list of branch names. When set, JIRA notifications are +// only sent for deployments on the listed branches. When empty, no filtering +// is applied. Checked before required variable validation so channels not +// configured for the current branch can short-circuit cleanly. +// ----------------------------------------------------------------------------- + +$notify_jira_branches = getenv_default('VORTEX_NOTIFY_JIRA_BRANCHES', ''); +if ($notify_jira_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_jira_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping JIRA notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + // ----------------------------------------------------------------------------- // JIRA notification project name. diff --git a/.vortex/tooling/src/notify-newrelic b/.vortex/tooling/src/notify-newrelic index 446205b75..c4df6b4b9 100755 --- a/.vortex/tooling/src/notify-newrelic +++ b/.vortex/tooling/src/notify-newrelic @@ -92,6 +92,21 @@ if (empty($newrelic_enabled)) { quit(); } +// Branch filter gate. +// +// Comma-separated list of branch names. When set, New Relic notifications +// are only sent for deployments on the listed branches. When empty, no +// filtering is applied. Defaults to the long-lived branches. +$notify_newrelic_branches = getenv_default('VORTEX_NOTIFY_NEWRELIC_BRANCHES', 'main,master,develop'); +if ($notify_newrelic_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_newrelic_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping New Relic notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + info('Started New Relic notification.'); // Skip if this is a pre-deployment event (New Relic only for post-deployment). diff --git a/.vortex/tooling/src/notify-slack b/.vortex/tooling/src/notify-slack index cd5de135d..9a8140841 100755 --- a/.vortex/tooling/src/notify-slack +++ b/.vortex/tooling/src/notify-slack @@ -15,6 +15,25 @@ namespace DrevOps\VortexTooling; require_once __DIR__ . '/helpers.php'; execute_override(basename(__FILE__)); +// ----------------------------------------------------------------------------- +// Branch filter gate. +// +// Comma-separated list of branch names. When set, Slack notifications are +// only sent for deployments on the listed branches. When empty, no filtering +// is applied. Checked before required variable validation so channels not +// configured for the current branch can short-circuit cleanly. +// ----------------------------------------------------------------------------- + +$notify_slack_branches = getenv_default('VORTEX_NOTIFY_SLACK_BRANCHES', ''); +if ($notify_slack_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_slack_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping Slack notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + // ----------------------------------------------------------------------------- // Slack notification project name. diff --git a/.vortex/tooling/src/notify-webhook b/.vortex/tooling/src/notify-webhook index 16846f12b..80dbb96a6 100755 --- a/.vortex/tooling/src/notify-webhook +++ b/.vortex/tooling/src/notify-webhook @@ -13,6 +13,25 @@ namespace DrevOps\VortexTooling; require_once __DIR__ . '/helpers.php'; execute_override(basename(__FILE__)); +// ----------------------------------------------------------------------------- +// Branch filter gate. +// +// Comma-separated list of branch names. When set, Webhook notifications are +// only sent for deployments on the listed branches. When empty, no filtering +// is applied. Checked before required variable validation so channels not +// configured for the current branch can short-circuit cleanly. +// ----------------------------------------------------------------------------- + +$notify_webhook_branches = getenv_default('VORTEX_NOTIFY_WEBHOOK_BRANCHES', ''); +if ($notify_webhook_branches !== '') { + $current_branch = (string) (getenv('VORTEX_NOTIFY_BRANCH') ?: ''); + $branch_list = array_map('trim', explode(',', $notify_webhook_branches)); + if (!in_array($current_branch, $branch_list, TRUE)) { + pass(sprintf('Skipping Webhook notification for branch \'%s\'.', $current_branch)); + quit(); + } +} + // ----------------------------------------------------------------------------- // Webhook notification project name. diff --git a/.vortex/tooling/src/provision b/.vortex/tooling/src/provision index f2cd60de6..b89d86f7b 100755 --- a/.vortex/tooling/src/provision +++ b/.vortex/tooling/src/provision @@ -56,6 +56,12 @@ $provision_verify_config = getenv_default('VORTEX_PROVISION_VERIFY_CONFIG_UNCHAN // Skip cache rebuild after database updates. $provision_cache_rebuild_after_db_update_skip = getenv_default('VORTEX_PROVISION_CACHE_REBUILD_AFTER_DB_UPDATE_SKIP', '0'); +// Repeat configuration import after the initial import. +// +// Useful when update hooks introduce new configuration that affects +// subsequent configuration imports (e.g., new config_split settings). +$provision_config_import_repeat = getenv_default('VORTEX_PROVISION_CONFIG_IMPORT_REPEAT', '0'); + // Provision database dump file. // // If not set, it will be auto-discovered from the VORTEX_PROVISION_DB_DIR @@ -548,6 +554,13 @@ if ($site_has_config_files) { pass('Completed configuration import.'); echo PHP_EOL; + if ($provision_config_import_repeat === '1') { + task('Repeating configuration import.'); + drush('config:import'); + pass('Completed repeated configuration import.'); + echo PHP_EOL; + } + // Import config_split configuration if the module is installed. // Drush deploy does not import config_split configuration on the first run. // @see https://github.com/drush-ops/drush/issues/2449 diff --git a/.vortex/tooling/tests/Unit/DownloadDbAcquiaTest.php b/.vortex/tooling/tests/Unit/DownloadDbAcquiaTest.php index aa149dee7..7b6f9b09e 100644 --- a/.vortex/tooling/tests/Unit/DownloadDbAcquiaTest.php +++ b/.vortex/tooling/tests/Unit/DownloadDbAcquiaTest.php @@ -156,10 +156,10 @@ public function testDownloadRequestFails(): void { 'url' => 'https://cloud.acquia.com/api/environments/env-id-prod/databases/mydb/backups?sort=created', 'response' => ['body' => json_encode(['_embedded' => ['items' => [['id' => '12345']]]])], ], - // Backup download URL. + // Backup download URL: API responds with 302 redirect to S3. [ 'url' => 'https://cloud.acquia.com/api/environments/env-id-prod/databases/mydb/backups/12345/actions/download', - 'response' => ['body' => json_encode(['url' => 'https://acquia-backup.s3.amazonaws.com/backup.sql.gz'])], + 'response' => ['status' => 302, 'info' => ['redirect_url' => 'https://acquia-backup.s3.amazonaws.com/backup.sql.gz']], ], // Download request fails. [ @@ -177,7 +177,8 @@ public function testInvalidGzip(): void { mkdir($db_dir, 0755, TRUE); // Pre-create an invalid gz file. - file_put_contents($db_dir . '/mydb_backup_12345.sql.gz', 'NOT VALID GZIP DATA'); + $invalid_gz = $db_dir . '/mydb_backup_12345.sql.gz'; + file_put_contents($invalid_gz, 'NOT VALID GZIP DATA'); $this->mockRequestMultiple([ [ @@ -199,6 +200,9 @@ public function testInvalidGzip(): void { ]); $this->runScriptError('src/download-db-acquia', 'Downloaded file is not a valid gzip archive'); + + // Invalid file is left in place for inspection. + $this->assertFileExists($invalid_gz); } public function testNoBackups(): void { @@ -246,10 +250,10 @@ public function testBackupUrlEmpty(): void { 'url' => 'https://cloud.acquia.com/api/environments/env-id-prod/databases/mydb/backups?sort=created', 'response' => ['body' => json_encode(['_embedded' => ['items' => [['id' => '12345']]]])], ], - // Backup download URL returns empty. + // Backup download URL returns no redirect (empty redirect_url). [ 'url' => 'https://cloud.acquia.com/api/environments/env-id-prod/databases/mydb/backups/12345/actions/download', - 'response' => ['body' => json_encode([])], + 'response' => ['info' => ['redirect_url' => '']], ], ]); diff --git a/.vortex/tooling/tests/Unit/NotifyEmailTest.php b/.vortex/tooling/tests/Unit/NotifyEmailTest.php index 0a9a140ab..886751968 100644 --- a/.vortex/tooling/tests/Unit/NotifyEmailTest.php +++ b/.vortex/tooling/tests/Unit/NotifyEmailTest.php @@ -119,6 +119,29 @@ public function testPreDeploymentEventSkipped(): void { $this->runScriptEarlyPass('src/notify-email', 'Skipping email notification for pre_deployment event'); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_EMAIL_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->runScriptEarlyPass('src/notify-email', "Skipping email notification for branch 'develop'."); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_EMAIL_BRANCHES', 'main,develop'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->mockMail([ + 'to' => 'to@example.com', + 'subject' => 'test-project deployment notification of main', + 'message' => $this->defaultMessageMatcher(), + 'result' => TRUE, + ]); + + $output = $this->runScript('src/notify-email'); + + $this->assertStringContainsString('Finished email notification', $output); + } + #[DataProvider('dataProviderMissingRequiredVariables')] public function testMissingRequiredVariables(string $var_name): void { $this->envUnset($var_name); diff --git a/.vortex/tooling/tests/Unit/NotifyGithubTest.php b/.vortex/tooling/tests/Unit/NotifyGithubTest.php index d811a5a23..751dbd81f 100644 --- a/.vortex/tooling/tests/Unit/NotifyGithubTest.php +++ b/.vortex/tooling/tests/Unit/NotifyGithubTest.php @@ -30,6 +30,31 @@ protected function setUp(): void { ]); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_GITHUB_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'feature/x'); + + $this->runScriptEarlyPass('src/notify-github', "Skipping GitHub notification for branch 'feature/x'."); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_GITHUB_BRANCHES', 'main,develop'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + $this->envSet('VORTEX_NOTIFY_GITHUB_EVENT', 'pre_deployment'); + + $this->mockRequestPost( + 'https://api.github.com/repos/owner/repo/deployments', + $this->callback(fn(): true => TRUE), + ['Authorization: token ghp_test123456', 'Accept: application/vnd.github.v3+json'], + 10, + ['status' => 201, 'body' => '{"id": 123456789}'] + ); + + $output = $this->runScript('src/notify-github'); + + $this->assertStringContainsString('Finished GitHub notification', $output); + } + public function testSuccessfulPreDeploymentNotification(): void { $this->envSet('VORTEX_NOTIFY_GITHUB_EVENT', 'pre_deployment'); diff --git a/.vortex/tooling/tests/Unit/NotifyJiraTest.php b/.vortex/tooling/tests/Unit/NotifyJiraTest.php index 4c24228cb..5e0cf609a 100644 --- a/.vortex/tooling/tests/Unit/NotifyJiraTest.php +++ b/.vortex/tooling/tests/Unit/NotifyJiraTest.php @@ -204,6 +204,43 @@ public function testPreDeploymentEventSkipped(): void { $this->runScriptEarlyPass('src/notify-jira', 'Skipping JIRA notification for pre_deployment event'); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_JIRA_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'feature/x'); + + $this->runScriptEarlyPass('src/notify-jira', "Skipping JIRA notification for branch 'feature/x'."); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_JIRA_BRANCHES', 'main,feature/TEST-123-test-feature'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'feature/TEST-123-test-feature'); + + $this->mockRequestGet( + 'https://jira.example.com/rest/api/3/myself', + [ + 'Authorization: Basic ' . base64_encode('user@example.com:test-token-123'), + 'Content-Type: application/json', + ], + 10, + ['status' => 200, 'body' => '{"accountId": "123456789012345678901234"}'] + ); + + $this->mockRequestPost( + 'https://jira.example.com/rest/api/3/issue/TEST-123/comment', + $this->callback(fn(): true => TRUE), + [ + 'Authorization: Basic ' . base64_encode('user@example.com:test-token-123'), + 'Content-Type: application/json', + ], + 10, + ['status' => 201, 'body' => '{"id": "10001"}'] + ); + + $output = $this->runScript('src/notify-jira'); + + $this->assertStringContainsString('Finished JIRA notification', $output); + } + public function testAuthenticationFailure(): void { // Mock authentication check returning invalid account ID. $this->mockRequestGet( diff --git a/.vortex/tooling/tests/Unit/NotifyNewrelicTest.php b/.vortex/tooling/tests/Unit/NotifyNewrelicTest.php index 15cbf59d3..bd3333d8e 100644 --- a/.vortex/tooling/tests/Unit/NotifyNewrelicTest.php +++ b/.vortex/tooling/tests/Unit/NotifyNewrelicTest.php @@ -31,9 +31,47 @@ protected function setUp(): void { 'VORTEX_NOTIFY_NEWRELIC_USER' => 'Deploy Bot', 'VORTEX_NOTIFY_NEWRELIC_EVENT' => 'post_deployment', 'VORTEX_NOTIFY_NEWRELIC_APPID' => '12345678', + // Match the 'main,master,develop' default of VORTEX_NOTIFY_NEWRELIC_BRANCHES. + 'VORTEX_NOTIFY_BRANCH' => 'main', ]); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_NEWRELIC_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->runScriptEarlyPass('src/notify-newrelic', "Skipping New Relic notification for branch 'develop'."); + } + + public function testEnabledGateTakesPrecedenceOverBranchFilter(): void { + // When disabled, the enabled gate must short-circuit before the branch + // filter runs, even if the branch is not in the allowed list. + $this->envUnset('VORTEX_NOTIFY_NEWRELIC_ENABLED'); + $this->envSet('VORTEX_NOTIFY_NEWRELIC_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->runScriptEarlyPass('src/notify-newrelic', 'New Relic is not enabled'); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_NEWRELIC_BRANCHES', 'main,develop'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + $this->envSet('VORTEX_NOTIFY_NEWRELIC_REVISION', 'v1.0.0'); + + $this->mockRequestPost( + 'https://api.newrelic.com/v2/applications/12345678/deployments.json', + $this->callback(fn(): true => TRUE), + ['Api-Key: NRAK-TEST123456', 'Content-Type: application/json'], + 10, + ['status' => 201] + ); + + $output = $this->runScript('src/notify-newrelic'); + + $this->assertStringContainsString('Started New Relic notification', $output); + $this->assertStringContainsString('Finished New Relic notification', $output); + } + public function testNotificationSkippedWhenNotEnabled(): void { $this->envUnset('VORTEX_NOTIFY_NEWRELIC_ENABLED'); diff --git a/.vortex/tooling/tests/Unit/NotifySlackTest.php b/.vortex/tooling/tests/Unit/NotifySlackTest.php index feb1e6502..6a2b78590 100644 --- a/.vortex/tooling/tests/Unit/NotifySlackTest.php +++ b/.vortex/tooling/tests/Unit/NotifySlackTest.php @@ -163,6 +163,30 @@ public function testSuccessfulNotificationWithCustomBot(): void { $this->assertStringContainsString('Finished Slack notification', $output); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_SLACK_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->runScriptEarlyPass('src/notify-slack', "Skipping Slack notification for branch 'develop'."); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_SLACK_BRANCHES', 'main,develop'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->mockRequestPost( + 'https://hooks.slack.com/services/T00/B00/XXXX', + $this->callback(fn(): true => TRUE), + ['Content-Type: application/json'], + 10, + ['status' => 200] + ); + + $output = $this->runScript('src/notify-slack'); + + $this->assertStringContainsString('Finished Slack notification', $output); + } + #[DataProvider('dataProviderMissingRequiredVariables')] public function testMissingRequiredVariables(string $var_name): void { $this->envUnset($var_name); diff --git a/.vortex/tooling/tests/Unit/NotifyWebhookTest.php b/.vortex/tooling/tests/Unit/NotifyWebhookTest.php index 0f5d4e8f8..9346ab435 100644 --- a/.vortex/tooling/tests/Unit/NotifyWebhookTest.php +++ b/.vortex/tooling/tests/Unit/NotifyWebhookTest.php @@ -67,6 +67,28 @@ public function testPreDeploymentEventSkipped(): void { $this->runScriptEarlyPass('src/notify-webhook', 'Skipping Webhook notification for pre_deployment event'); } + public function testNotificationSkippedWhenBranchNotInFilter(): void { + $this->envSet('VORTEX_NOTIFY_WEBHOOK_BRANCHES', 'main,master'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->runScriptEarlyPass('src/notify-webhook', "Skipping Webhook notification for branch 'develop'."); + } + + public function testNotificationProceedsWhenBranchInFilter(): void { + $this->envSet('VORTEX_NOTIFY_WEBHOOK_BRANCHES', 'main,develop'); + $this->envSet('VORTEX_NOTIFY_BRANCH', 'develop'); + + $this->mockRequest( + 'https://webhook.example.com/endpoint', + ['method' => 'POST'], + ['status' => 200] + ); + + $output = $this->runScript('src/notify-webhook'); + + $this->assertStringContainsString('Finished Webhook notification', $output); + } + #[DataProvider('dataProviderMissingRequiredVariables')] public function testMissingRequiredVariables(string $var_name): void { $this->envUnset($var_name); diff --git a/.vortex/tooling/tests/Unit/ProvisionTest.php b/.vortex/tooling/tests/Unit/ProvisionTest.php index 5474e9d9e..24067ced2 100644 --- a/.vortex/tooling/tests/Unit/ProvisionTest.php +++ b/.vortex/tooling/tests/Unit/ProvisionTest.php @@ -447,7 +447,10 @@ public function testPostOperationsWithConfigImport(): void { $this->createDbDumpFile(); $this->createConfigFiles(); - $this->envSet('VORTEX_PROVISION_POST_OPERATIONS_SKIP', '0'); + $this->envSetMultiple([ + 'VORTEX_PROVISION_POST_OPERATIONS_SKIP' => '0', + 'VORTEX_PROVISION_CONFIG_IMPORT_REPEAT' => '0', + ]); $this->mockDrushStartupSequenceWithConfig(TRUE); @@ -516,11 +519,87 @@ public function testPostOperationsWithConfigImport(): void { $this->assertStringContainsString('Completed running database updates.', $output); $this->assertStringContainsString('Cache was cleared.', $output); $this->assertStringContainsString('Completed configuration import.', $output); + $this->assertStringNotContainsString('Repeating configuration import.', $output); $this->assertStringContainsString('Completed config_split configuration import.', $output); $this->assertStringContainsString('Cache was rebuilt.', $output); $this->assertStringContainsString('Completed deployment hooks.', $output); } + public function testPostOperationsWithRepeatedConfigImport(): void { + $this->createDbDumpFile(); + $this->createConfigFiles(); + + $this->envSet('VORTEX_PROVISION_POST_OPERATIONS_SKIP', '0'); + $this->envSet('VORTEX_PROVISION_CONFIG_IMPORT_REPEAT', '1'); + + $this->mockDrushStartupSequenceWithConfig(TRUE); + + // Drush php:eval (environment). + $this->mockPassthru([ + 'cmd' => $this->drushCmd("php:eval \"print \\Drupal\\core\\Site\\Settings::get('environment');\""), + 'output' => 'production', + 'result_code' => 0, + ]); + + // Drush config-set system.site uuid. + $this->mockPassthru([ + 'cmd' => $this->drushCmd("config-set system.site uuid 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'"), + 'result_code' => 0, + ]); + + // Drush updatedb. + $this->mockPassthru([ + 'cmd' => $this->drushCmd('updatedb --no-cache-clear'), + 'result_code' => 0, + ]); + + // Drush cache:rebuild (after database updates). + $this->mockPassthru([ + 'cmd' => $this->drushCmd('cache:rebuild'), + 'result_code' => 0, + ]); + + // Drush config:import (initial). + $this->mockPassthru([ + 'cmd' => $this->drushCmd('config:import'), + 'result_code' => 0, + ]); + + // Drush config:import (repeated). + $this->mockPassthru([ + 'cmd' => $this->drushCmd('config:import'), + 'result_code' => 0, + ]); + + // Drush pm:list (config_split check) - module not enabled. + $this->mockPassthru([ + 'cmd' => $this->drushCmd('pm:list --status=enabled'), + 'output' => '', + 'result_code' => 0, + ]); + + // Drush cache:rebuild (post-provision). + $this->mockPassthru([ + 'cmd' => $this->drushCmd('cache:rebuild'), + 'result_code' => 0, + ]); + + // Drush deploy:hook. + $this->mockPassthru([ + 'cmd' => $this->drushCmd('deploy:hook'), + 'result_code' => 0, + ]); + + $this->mockQuit(0); + $this->expectException(QuitSuccessException::class); + + $output = $this->runScript('src/provision'); + + $this->assertStringContainsString('Completed configuration import.', $output); + $this->assertStringContainsString('Repeating configuration import.', $output); + $this->assertStringContainsString('Completed repeated configuration import.', $output); + } + public function testSummaryOutputIncludesNewVariables(): void { $this->createDbDumpFile();