Files
coolify/tests/Feature/DeploymentEmailCommitDetailsTest.php
22a8c09dd6 Next (#12202)
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rosslh <ross@rosshill.ca>
Co-authored-by: Abdulfetah Suudi <suudiabdulfetah@gmail.com>
Co-authored-by: Dharmveer <51308779+dharmveer97@users.noreply.github.com>
Co-authored-by: Maxwell <136101+mxswd@users.noreply.github.com>
2026-10-19 10:07:23 +02:00

320 lines
13 KiB
PHP

<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Notifications\Email as NotificationEmail;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Notifications\Application\DeploymentFailed;
use App\Notifications\Application\DeploymentSuccess;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
uses(RefreshDatabase::class);
const DEPLOYMENT_EMAIL_COMMIT_SHA = '3f9c2a7b1d4e5f60718293a4b5c6d7e8f9012345';
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = StandaloneDocker::factory()->create([
'server_id' => $server->id,
'network' => 'commit-details-'.fake()->unique()->word(),
]);
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->application = Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'git_repository' => 'https://github.com/acme/shop.git',
'git_branch' => 'main',
'fqdn' => 'https://shop.example.com',
]);
});
function createDeploymentRow(Application $application, array $attributes = []): ApplicationDeploymentQueue
{
return ApplicationDeploymentQueue::create([
'application_id' => $application->id,
'deployment_uuid' => 'deploy-'.fake()->unique()->uuid(),
'commit' => DEPLOYMENT_EMAIL_COMMIT_SHA,
'commit_message' => "Add checkout page\n\nHandles card and wallet payments.",
'status' => 'finished',
...$attributes,
]);
}
function enableCommitDetails(Team $team, bool $enabled = true): void
{
$team->emailNotificationSettings->update(['deployment_commit_details_email_notifications' => $enabled]);
$team->refresh();
}
function renderDeploymentMail(string $notificationClass, Application $application, ApplicationDeploymentQueue $deployment, Team $team, ?ApplicationPreview $preview = null): string
{
return (string) (new $notificationClass($application, $deployment->deployment_uuid, $preview, $deployment))->toMail($team)->render();
}
it('defaults the commit details setting to off', function () {
expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse();
});
it('includes commit details in deployment emails when enabled', function (string $notificationClass) {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application);
$html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team);
expect($html)
->toContain('3f9c2a7')
->toContain('https://github.com/acme/shop/commit/'.DEPLOYMENT_EMAIL_COMMIT_SHA)
->toContain('main')
->toContain('Add checkout page')
->toContain('Handles card and wallet payments.');
})->with([DeploymentSuccess::class, DeploymentFailed::class]);
it('leaves deployment emails unchanged when the setting is off', function (string $notificationClass) {
enableCommitDetails($this->team, false);
$deployment = createDeploymentRow($this->application);
$html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team);
expect($html)
->not->toContain('3f9c2a7')
->not->toContain('Add checkout page')
->toContain('View Deployment Logs');
})->with([DeploymentSuccess::class, DeploymentFailed::class]);
it('hides the commit block when the deployment has no commit information', function () {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application, ['commit' => 'HEAD', 'commit_message' => null]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team);
expect($html)
->not->toContain('Commit:')
->not->toContain('Commit message:')
->toContain('View Deployment Logs');
});
it('shows the commit for preview deployments without the base branch', function () {
enableCommitDetails($this->team);
$preview = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/acme/shop/pull/42',
'fqdn' => 'https://pr-42.shop.example.com',
]);
$deployment = createDeploymentRow($this->application, ['pull_request_id' => 42]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team, $preview);
expect($html)
->toContain('Pull request #42')
->toContain('3f9c2a7')
->toContain('Add checkout page')
->not->toContain(' on main');
});
it('escapes html and markdown in commit messages', function () {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application, [
'commit_message' => "Fix <script>alert(1)</script> bug\n[Verify your account](https://evil.example.com)\n# Big heading",
]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team);
expect($html)
->not->toContain('<script>alert(1)</script>')
->toContain('&lt;script&gt;')
->not->toContain('href="https://evil.example.com"')
->toContain('[Verify your account](https://evil.example.com)')
->not->toContain('<h1>Big heading</h1>')
->toContain('# Big heading');
});
it('lets team admins toggle commit details from email notification settings', function () {
$admin = User::factory()->create();
$this->team->members()->attach($admin->id, ['role' => 'admin']);
$this->actingAs($admin);
session(['currentTeam' => $this->team]);
Cache::flush();
Livewire::test(NotificationEmail::class)
->assertSet('deploymentCommitDetailsEmailNotifications', false)
->set('deploymentCommitDetailsEmailNotifications', true)
->call('instantSaveDeploymentCommitDetails')
->assertHasNoErrors();
expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue();
});
it('does not let team members change the commit details setting', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Cache::flush();
Livewire::test(NotificationEmail::class)
->set('deploymentCommitDetailsEmailNotifications', true)
->call('instantSaveDeploymentCommitDetails')
->assertSet('deploymentCommitDetailsEmailNotifications', false);
expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse();
});
it('does not let admins of another team change the commit details setting', function () {
$otherTeam = Team::factory()->create();
$outsider = User::factory()->create();
$otherTeam->members()->attach($outsider->id, ['role' => 'admin']);
$this->actingAs($outsider);
session(['currentTeam' => $otherTeam]);
Cache::flush();
Livewire::test(NotificationEmail::class)
->set('deploymentCommitDetailsEmailNotifications', true)
->call('instantSaveDeploymentCommitDetails');
expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse()
->and($otherTeam->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue();
});
function bigMarkdownCommitMessage(): string
{
return implode("\n", [
'feat(checkout): rebuild payment flow',
'',
'# Heading one',
'Setext heading',
'==============',
'## Heading two',
'- bullet one',
'* bullet two',
'+ bullet three',
'1. first step',
'2. second step',
'> quoted text',
' indented code line',
'```php',
'echo "hello";',
'```',
'| Column A | Column B |',
'| -------- | -------- |',
'| cell 1 | cell 2 |',
'**bold** _italic_ ~~strike~~ `inline code`',
'[Reset your password](https://evil.example.com/reset)',
'![tracking pixel](https://evil.example.com/pixel.png)',
'<https://evil.example.com/autolink>',
'<img src="https://evil.example.com/x.png" onerror="alert(1)">',
'<a href="https://evil.example.com">click me</a>',
'---',
'Co-authored-by: Jane Doe <jane@example.com>',
'Emoji check 🚀 ✅',
'',
str_repeat('Long body sentence for truncation. ', 60),
'THIS_TAIL_SHOULD_BE_TRUNCATED',
]);
}
it('renders a big markdown commit message as plain safe text', function () {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application, ['commit_message' => bigMarkdownCommitMessage()]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team);
expect($html)
->toContain('feat(checkout): rebuild payment flow')
->toContain('# Heading one')
->toContain('==============')
->toContain('- bullet one')
->toContain('1. first step')
->toContain('```php')
->toContain('| Column A | Column B |')
->toContain('**bold** _italic_ ~~strike~~ `inline code`')
->toContain('Co-authored-by: Jane Doe &lt;jane@example.com&gt;')
->toContain('Emoji check 🚀 ✅')
->not->toContain("\u{FFFD}")
->toContain('Long body sentence for truncation.')
->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED')
->not->toContain('href="https://evil.example.com')
->not->toContain('src="https://evil.example.com')
->not->toContain('<img')
->not->toContain('<a href="https://evil')
->not->toContain('<h1>')
->not->toContain('<h2>')
->not->toContain('<ul>')
->not->toContain('<ol>')
->not->toContain('<blockquote>')
->not->toContain('<pre>')
->not->toContain('<table>')
->not->toContain('<strong>bold</strong>')
->toContain('---<br />');
});
it('keeps the email working when a commit message has invalid utf-8 bytes', function () {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application, ['commit_message' => "Fix caf\xE9 menu (latin-1 byte)"]);
$html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team);
expect($html)
->toContain('Fix caf')
->toContain('menu (latin-1 byte)')
->toContain('View Deployment Logs');
});
it('builds commit details without querying the deployment queue', function (string $notificationClass) {
enableCommitDetails($this->team);
$deployment = createDeploymentRow($this->application);
DB::flushQueryLog();
DB::enableQueryLog();
$notification = new $notificationClass($this->application, $deployment->deployment_uuid, null, $deployment);
$queries = collect(DB::getQueryLog())->pluck('query');
DB::disableQueryLog();
expect($queries->filter(fn (string $query): bool => str_contains($query, 'application_deployment_queues')))->toBeEmpty()
->and($notification->commit_sha)->toBe(DEPLOYMENT_EMAIL_COMMIT_SHA);
})->with([DeploymentSuccess::class, DeploymentFailed::class]);
it('keeps the old constructor working without a deployment record', function () {
enableCommitDetails($this->team);
$html = (string) (new DeploymentSuccess($this->application, 'legacy-call'))->toMail($this->team)->render();
expect($html)
->toContain('View Deployment Logs')
->not->toContain('Commit message:');
});
it('passes the in-memory deployment record from the deployment job', function () {
Notification::fake();
$this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'deployment_success_email_notifications' => true]);
$deployment = createDeploymentRow($this->application);
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
foreach (['application' => $this->application, 'deployment_uuid' => $deployment->deployment_uuid, 'preview' => null, 'application_deployment_queue' => $deployment] as $name => $value) {
$reflection->getProperty($name)->setValue($job, $value);
}
$reflection->getMethod('sendDeploymentNotification')->invoke($job, DeploymentSuccess::class);
Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === DEPLOYMENT_EMAIL_COMMIT_SHA
&& str_starts_with($notification->commit_message, 'Add checkout page'));
});