mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-14 08:42:38 -04:00
Count invalid keys, tokens, and deleted webhooks in a new failure_count column on each notification settings table, like servers.unreachable_count. After 3 failures in a row the channel is disabled and the team is told by instance email and its other channels. Rate limits and server errors are retried and not counted; test notifications never count.
230 lines
11 KiB
PHP
230 lines
11 KiB
PHP
<?php
|
|
|
|
use App\Exceptions\NonReportableException;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Notifications\Channels\EmailChannel;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Notifications\Messages\MailMessage;
|
|
use Illuminate\Notifications\Notification;
|
|
use Symfony\Component\Process\Process;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::forceCreate(['id' => 0]);
|
|
$this->resendStub = null;
|
|
$this->team = Team::factory()->create();
|
|
$user = User::factory()->create(['email' => 'test@example.com']);
|
|
$this->team->members()->attach($user->id, ['role' => 'owner']);
|
|
|
|
$this->team->emailNotificationSettings->update([
|
|
'resend_enabled' => true,
|
|
'smtp_enabled' => false,
|
|
'use_instance_email_settings' => false,
|
|
'smtp_from_name' => 'Test Sender',
|
|
'smtp_from_address' => 'sender@example.com',
|
|
'resend_api_key' => 'test_api_key',
|
|
]);
|
|
$this->team->refresh();
|
|
|
|
// A test notification shows every error to the user and never counts as a delivery failure.
|
|
$this->notification = new class extends Notification
|
|
{
|
|
public bool $isTestNotification = true;
|
|
|
|
public function toMail(object $notifiable): MailMessage
|
|
{
|
|
return (new MailMessage)->subject('Test Email')->line('Test');
|
|
}
|
|
};
|
|
|
|
$this->deliveryNotification = new class extends Notification
|
|
{
|
|
public function toMail(object $notifiable): MailMessage
|
|
{
|
|
return (new MailMessage)->subject('Deployment')->line('Deployed');
|
|
}
|
|
};
|
|
});
|
|
|
|
afterEach(function () {
|
|
$this->resendStub?->stop();
|
|
putenv('RESEND_BASE_URL');
|
|
});
|
|
|
|
/**
|
|
* Point the real Resend client at a local HTTP stub that answers every request
|
|
* with the given status and JSON body, so the whole Resend SDK path is exercised.
|
|
*/
|
|
function fakeResendResponse(int $status, array $body): Process
|
|
{
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
|
$port = (int) parse_url('tcp://'.stream_socket_get_name($socket, false), PHP_URL_PORT);
|
|
fclose($socket);
|
|
|
|
$router = sys_get_temp_dir()."/coolify-resend-stub-{$port}.php";
|
|
file_put_contents($router, <<<'PHP'
|
|
<?php
|
|
http_response_code((int) getenv('RESEND_STUB_STATUS'));
|
|
header('Content-Type: application/json');
|
|
echo getenv('RESEND_STUB_BODY');
|
|
PHP);
|
|
|
|
$server = new Process(
|
|
[PHP_BINARY, '-S', "127.0.0.1:{$port}", $router],
|
|
env: ['RESEND_STUB_STATUS' => (string) $status, 'RESEND_STUB_BODY' => json_encode($body)],
|
|
);
|
|
$server->start();
|
|
|
|
$deadline = microtime(true) + 5;
|
|
while (! @fsockopen('127.0.0.1', $port) && microtime(true) < $deadline) {
|
|
usleep(20_000);
|
|
}
|
|
|
|
putenv("RESEND_BASE_URL=http://127.0.0.1:{$port}");
|
|
|
|
return $server;
|
|
}
|
|
|
|
/**
|
|
* Answer with the legacy nested error shape ({"error": {"message", "type", "code"}}).
|
|
*/
|
|
function fakeResendError(int $code, string $message): Process
|
|
{
|
|
return fakeResendResponse($code, ['error' => ['message' => $message, 'type' => 'error', 'code' => $code]]);
|
|
}
|
|
|
|
it('throws user-friendly error for invalid Resend API key (403)', function () {
|
|
$this->resendStub = fakeResendError(403, 'API key is invalid.');
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(
|
|
NonReportableException::class,
|
|
'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'
|
|
);
|
|
});
|
|
|
|
it('throws user-friendly error for restricted Resend API key (401)', function () {
|
|
$this->resendStub = fakeResendError(401, 'This API key is restricted to only send emails.');
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(
|
|
NonReportableException::class,
|
|
'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.'
|
|
);
|
|
});
|
|
|
|
it('throws user-friendly error for rate limiting (429)', function () {
|
|
$this->resendStub = fakeResendError(429, 'Too many requests.');
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(Exception::class, 'Resend rate limit exceeded. Please try again in a few minutes.');
|
|
});
|
|
|
|
it('throws user-friendly error for validation errors (400)', function () {
|
|
$this->resendStub = fakeResendError(400, 'Invalid email format.');
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(NonReportableException::class, 'Email validation failed: Invalid email format.');
|
|
});
|
|
|
|
it('throws user-friendly error for network/transport errors', function () {
|
|
$socket = stream_socket_server('tcp://127.0.0.1:0');
|
|
$closedPort = (int) parse_url('tcp://'.stream_socket_get_name($socket, false), PHP_URL_PORT);
|
|
fclose($socket);
|
|
putenv("RESEND_BASE_URL=http://127.0.0.1:{$closedPort}");
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(Exception::class, 'Unable to connect to Resend API. Please check your internet connection and try again.');
|
|
});
|
|
|
|
it('throws generic error with message for unknown error codes', function () {
|
|
$this->resendStub = fakeResendError(500, 'Internal server error.');
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(Exception::class, 'Failed to send email via Resend: Internal server error.');
|
|
});
|
|
|
|
it('surfaces a friendly message for real Resend error bodies', function (int $status, string $name, string $message, string $exception, string $expected) {
|
|
$this->resendStub = fakeResendResponse($status, ['statusCode' => $status, 'name' => $name, 'message' => $message]);
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow($exception, $expected);
|
|
})->with([
|
|
'invalid api key as returned by the live API (401)' => [401, 'validation_error', 'API key is invalid', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'],
|
|
'invalid api key (403)' => [403, 'invalid_api_key', 'API key is invalid', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'],
|
|
'restricted api key (401)' => [401, 'restricted_api_key', 'This API key is restricted to only send emails', NonReportableException::class, 'Your Resend API key has restricted permissions. Please use an API key with Full Access permissions.'],
|
|
'unverified domain (403)' => [403, 'validation_error', 'The example.com domain is not verified.', NonReportableException::class, 'Email validation failed: The example.com domain is not verified.'],
|
|
'validation error (422)' => [422, 'validation_error', 'Invalid `to` field.', NonReportableException::class, 'Email validation failed: Invalid `to` field.'],
|
|
'rate limited (429)' => [429, 'rate_limit_exceeded', 'Too many requests.', Exception::class, 'Resend rate limit exceeded. Please try again in a few minutes.'],
|
|
'monthly quota exceeded (429)' => [429, 'monthly_quota_exceeded', 'You have reached your monthly email quota.', Exception::class, 'Failed to send email via Resend: You have reached your monthly email quota.'],
|
|
'suspended api key (403)' => [403, 'suspended_api_key', 'This API key has been suspended.', NonReportableException::class, 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.'],
|
|
'missing parameter (422)' => [422, 'missing_required_parameter', 'Missing `from` field.', NonReportableException::class, 'Email validation failed: Missing `from` field.'],
|
|
]);
|
|
|
|
it('does not report success when Resend returns no email id', function () {
|
|
$this->resendStub = fakeResendResponse(200, ['object' => 'error']);
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->toThrow(Exception::class, 'Failed to send email via Resend');
|
|
});
|
|
|
|
it('sends successfully when Resend returns an email id', function () {
|
|
$this->resendStub = fakeResendResponse(200, ['id' => '49a3999c-0ce1-4ea6-ab68-afcd6dc2e794']);
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))
|
|
->not->toThrow(Throwable::class);
|
|
});
|
|
|
|
it('counts an invalid team Resend key as a delivery failure without a retry', function () {
|
|
$this->resendStub = fakeResendResponse(401, ['statusCode' => 401, 'name' => 'validation_error', 'message' => 'API key is invalid']);
|
|
|
|
(new EmailChannel)->send($this->team, $this->deliveryNotification);
|
|
|
|
$settings = $this->team->emailNotificationSettings->fresh();
|
|
expect($settings->failure_count)->toBe(1)
|
|
->and($settings->resend_enabled)->toBeTrue();
|
|
});
|
|
|
|
it('disables team Resend after three invalid key failures in a row', function () {
|
|
$this->resendStub = fakeResendResponse(401, ['statusCode' => 401, 'name' => 'validation_error', 'message' => 'API key is invalid']);
|
|
|
|
foreach (range(1, 3) as $attempt) {
|
|
(new EmailChannel)->send($this->team, $this->deliveryNotification);
|
|
}
|
|
|
|
$settings = $this->team->emailNotificationSettings->fresh();
|
|
expect($settings->resend_enabled)->toBeFalse()
|
|
->and($settings->failure_count)->toBe(0)
|
|
->and($settings->resend_api_key)->toBe('test_api_key');
|
|
});
|
|
|
|
it('retries temporary Resend errors without counting them', function () {
|
|
$this->resendStub = fakeResendResponse(500, ['statusCode' => 500, 'name' => 'application_error', 'message' => 'Internal server error.']);
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->deliveryNotification))
|
|
->toThrow(Exception::class, 'Failed to send email via Resend: Internal server error.');
|
|
|
|
expect($this->team->emailNotificationSettings->fresh()->failure_count)->toBe(0);
|
|
});
|
|
|
|
it('does not count Resend errors for test emails', function () {
|
|
$this->resendStub = fakeResendResponse(401, ['statusCode' => 401, 'name' => 'validation_error', 'message' => 'API key is invalid']);
|
|
|
|
expect(fn () => (new EmailChannel)->send($this->team, $this->notification))->toThrow(NonReportableException::class);
|
|
|
|
expect($this->team->emailNotificationSettings->fresh()->failure_count)->toBe(0);
|
|
});
|
|
|
|
it('resets the failure count after a successful delivery', function () {
|
|
$this->team->emailNotificationSettings->increment('failure_count', 2);
|
|
$this->resendStub = fakeResendResponse(200, ['id' => '49a3999c-0ce1-4ea6-ab68-afcd6dc2e794']);
|
|
|
|
(new EmailChannel)->send($this->team, $this->deliveryNotification);
|
|
|
|
expect($this->team->emailNotificationSettings->fresh()->failure_count)->toBe(0);
|
|
});
|