From 7d699818e8f7395ab95471980bcbd889cf9dcf28 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:42:53 +0200 Subject: [PATCH] fix: harden Vultr create, Gmail identity, and provider retries Wrap Vultr server creation in DB transactions and delete the remote instance when local persistence fails (API and Livewire). Scope plus/dot email normalization to gmail.com/googlemail.com only. Use throw:false on DigitalOcean/Vultr HTTP retries, and normalize service log line counts via normalizeLogLines. --- .../Api/ServiceApplicationsController.php | 2 +- .../Api/ServiceDatabasesController.php | 2 +- app/Http/Controllers/Api/VultrController.php | 90 +++++++++++------ app/Livewire/Server/New/ByVultr.php | 60 ++++++++---- app/Services/DigitalOceanService.php | 2 +- app/Services/VultrService.php | 2 +- bootstrap/helpers/email.php | 9 +- tests/Feature/ForgotPasswordRateLimitTest.php | 25 ++++- tests/Feature/RegistrationRateLimitTest.php | 33 ++++++- tests/Feature/VultrApiTest.php | 98 +++++++++++++++++++ tests/Feature/VultrServerCreationTest.php | 51 ++++++++++ tests/Unit/Api/LogEndpointHelpersTest.php | 14 +++ tests/Unit/DigitalOceanServiceTest.php | 48 +++++++++ tests/Unit/EmailIdentityHelperTest.php | 8 +- tests/Unit/VultrServiceTest.php | 48 +++++++++ 15 files changed, 429 insertions(+), 63 deletions(-) diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 74b6c9db1..e1df34903 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -490,7 +490,7 @@ public function logs_by_uuid(Request $request): JsonResponse ], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); $logs = getContainerLogs($server, $containerName, $lines); return response()->json([ diff --git a/app/Http/Controllers/Api/ServiceDatabasesController.php b/app/Http/Controllers/Api/ServiceDatabasesController.php index 7befc3273..480ff4e55 100644 --- a/app/Http/Controllers/Api/ServiceDatabasesController.php +++ b/app/Http/Controllers/Api/ServiceDatabasesController.php @@ -311,7 +311,7 @@ public function logs(Request $request): JsonResponse return response()->json(['message' => 'Service database container is not running.'], 400); } - $lines = (int) ($request->query('lines', 100) ?: 100); + $lines = normalizeLogLines($request->query('lines')); return response()->json([ 'logs' => getContainerLogs($server, $containerName, $lines), diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 60b9d0a0a..51fad6a0b 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -15,6 +15,7 @@ use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use OpenApi\Attributes as OA; class VultrController extends Controller @@ -286,6 +287,10 @@ public function createServer(Request $request): JsonResponse return response()->json(['message' => 'Private key not found.'], 404); } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $vultrService = new VultrService($token->token); $publicKey = $privateKey->getPublicKey(); @@ -317,33 +322,41 @@ public function createServer(Request $request): JsonResponse } $vultrInstance = $vultrService->createInstance($params); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => $normalizedServerName, - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => $teamId, - 'private_key_id' => $privateKey->id, - 'cloud_provider_token_id' => $token->id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); - - $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); - if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { - $ipAddress = $assignedIpAddress; - $server->update([ - 'ip' => $assignedIpAddress, - 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + $server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, ]); - } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); + + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + report($e); + } if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -353,27 +366,48 @@ public function createServer(Request $request): JsonResponse 'team_id' => $teamId, 'server_uuid' => $server->uuid, 'server_name' => $server->name, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ]); return response()->json([ 'uuid' => $server->uuid, - 'vultr_instance_id' => $vultrInstance['id'], - 'ip' => $ipAddress, + 'vultr_instance_id' => $vultrInstanceId, + 'ip' => $server->ip, ])->setStatusCode(201); } catch (RateLimitException $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { $response->header('Retry-After', $e->retryAfter); } return $response; - } catch (\Throwable) { + } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + + logger()->error('Failed to create Vultr server', [ + 'error' => $e->getMessage(), + ]); + return response()->json(['message' => 'Failed to create Vultr server.'], 500); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array { $normalizedPublicKey = $this->normalizePublicKey($publicKey); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index fd0aa4c34..4246d5836 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -14,6 +14,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Livewire\Attributes\Locked; use Livewire\Component; @@ -377,9 +378,8 @@ private function providerDataErrorMessage(string $providerName, \Throwable $e, s return "{$providerName} API error: {$details}"; } - private function createVultrServer(string $token): array + private function createVultrServer(VultrService $vultrService): array { - $vultrService = new VultrService($token); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $publicKey = $privateKey->getPublicKey(); $existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey); @@ -419,6 +419,10 @@ public function submit(): mixed return null; } + $vultrService = null; + $vultrInstanceId = null; + $server = null; + try { $this->authorize('create', Server::class); @@ -437,20 +441,29 @@ public function submit(): mixed } $vultrService = new VultrService($this->getVultrToken()); - $vultrInstance = $this->createVultrServer($this->getVultrToken()); + $vultrInstance = $this->createVultrServer($vultrService); + $vultrInstanceId = (string) $vultrInstance['id']; $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; - $server = Server::create([ - 'name' => strtolower(trim($this->server_name)), - 'ip' => $ipAddress, - 'user' => 'root', - 'port' => 22, - 'team_id' => currentTeam()->id, - 'private_key_id' => $this->private_key_id, - 'cloud_provider_token_id' => $this->selected_token_id, - 'vultr_instance_id' => $vultrInstance['id'], - 'vultr_instance_status' => $vultrInstance['status'] ?? null, - ]); + $server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server { + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'vultr_instance_id' => $vultrInstanceId, + 'vultr_instance_status' => $vultrInstance['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return $server; + }); try { $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); @@ -466,10 +479,6 @@ public function submit(): mixed report($e); } - $server->proxy->set('status', 'exited'); - $server->proxy->set('type', ProxyTypes::TRAEFIK->value); - $server->save(); - if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, @@ -479,10 +488,25 @@ public function submit(): mixed return redirectRoute($this, 'server.show', [$server->uuid]); } catch (\Throwable $e) { + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + return handleError($e, $this); } } + private function deleteUntrackedInstance(?VultrService $vultrService, ?string $vultrInstanceId, ?Server $server): void + { + if (! $vultrService || ! $vultrInstanceId || $server) { + return; + } + + try { + $vultrService->deleteInstance($vultrInstanceId); + } catch (\Throwable $e) { + report($e); + } + } + public function render() { return view('livewire.server.new.by-vultr'); diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php index c8292e864..da1af942f 100644 --- a/app/Services/DigitalOceanService.php +++ b/app/Services/DigitalOceanService.php @@ -28,7 +28,7 @@ private function request(string $method, string $endpoint, array $data = []): ar } return $attempt * 100; - }) + }, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/app/Services/VultrService.php b/app/Services/VultrService.php index 0e335d3e0..347b7e3fb 100644 --- a/app/Services/VultrService.php +++ b/app/Services/VultrService.php @@ -17,7 +17,7 @@ private function request(string $method, string $endpoint, array $data = []): ar 'Authorization' => 'Bearer '.$this->token, ]) ->timeout(30) - ->retry(3, fn (int $attempt) => $attempt * 100) + ->retry(3, fn (int $attempt) => $attempt * 100, throw: false) ->{$method}($this->baseUrl.$endpoint, $data); if (! $response->successful()) { diff --git a/bootstrap/helpers/email.php b/bootstrap/helpers/email.php index a0b8ba67f..a4a311e04 100644 --- a/bootstrap/helpers/email.php +++ b/bootstrap/helpers/email.php @@ -8,9 +8,12 @@ function normalize_email_identity(?string $email): ?string return null; } - [$localPart, $domain] = explode('@', Str::lower($email), 2); - $localPart = Str::before($localPart, '+'); - $localPart = str_replace('.', '', $localPart); + [$localPart, $domain] = explode('@', Str::lower(trim($email)), 2); + + if (in_array($domain, ['gmail.com', 'googlemail.com'], true)) { + $localPart = Str::before($localPart, '+'); + $localPart = str_replace('.', '', $localPart); + } if (blank($localPart) || blank($domain)) { return null; diff --git a/tests/Feature/ForgotPasswordRateLimitTest.php b/tests/Feature/ForgotPasswordRateLimitTest.php index aaff953ba..02dbce650 100644 --- a/tests/Feature/ForgotPasswordRateLimitTest.php +++ b/tests/Feature/ForgotPasswordRateLimitTest.php @@ -27,9 +27,9 @@ it('rate limits dotted plus-address forgot password variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -42,7 +42,24 @@ $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/forgot-password', [ - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate forgot password buckets on ordinary domains', function () { + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 120)]) + ->post('/forgot-password', [ + 'email' => $email, + ]) + ->assertSessionHasNoErrors(); + } +}); diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 6288a85b8..7130ed36e 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -3,6 +3,7 @@ use App\Models\InstanceSettings; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); @@ -42,9 +43,9 @@ it('rate limits dotted plus-address variants of the same email identity across ips', function () { $emails = [ - 'ke.vinmcfadden+one@btinternet.com', - 'kevin.mcfadden+two@btinternet.com', - 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@btinternet.com', + 'ke.vinmcfadden+one@gmail.com', + 'kevin.mcfadden+two@gmail.com', + 'k.e.v.i.n.m.c.f.a.d.d.e.n+three@gmail.com', ]; foreach ($emails as $index => $email) { @@ -64,9 +65,33 @@ $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99']) ->post('/register', [ 'name' => 'Blocked User', - 'email' => 'k.evin.mcfadden+four@btinternet.com', + 'email' => 'k.evin.mcfadden+four@gmail.com', 'password' => 'Password1!@', 'password_confirmation' => 'Password1!@', ]) ->assertTooManyRequests(); }); + +it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () { + $registrationIpKey = 'registration:ip:'.sha1('127.0.0.1'); + $emails = [ + 'john.smith@example.com', + 'johnsmith@example.com', + 'johnsmith+one@example.com', + 'johnsmith+two@example.com', + ]; + + foreach ($emails as $index => $email) { + $this->post('/register', [ + 'name' => "Distinct User {$index}", + 'email' => $email, + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); + + auth()->logout(); + $this->flushSession(); + RateLimiter::clear($registrationIpKey); + } +}); diff --git a/tests/Feature/VultrApiTest.php b/tests/Feature/VultrApiTest.php index 9eb135baf..e9c0ee24a 100644 --- a/tests/Feature/VultrApiTest.php +++ b/tests/Feature/VultrApiTest.php @@ -301,6 +301,104 @@ function testPublicKey(): string ]); }); + test('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-persistence-fails', + 'main_ip' => '203.0.113.10', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-persistence-fails' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'persistence-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $response->assertServerError(); + $response->assertJson(['message' => 'Failed to create Vultr server.']); + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-persistence-fails', + ]); + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-persistence-fails'); + }); + + test('keeps the tracked Vultr server when public IP polling fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => testPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-polling-fails', + 'main_ip' => '0.0.0.0', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-polling-fails' => Http::response([ + 'error' => 'temporary polling failure', + ], 500), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/vultr', [ + 'cloud_provider_token_id' => $this->vultrToken->uuid, + 'region' => 'ewr', + 'plan' => 'vc2-1c-1gb', + 'os_id' => 2284, + 'name' => 'polling-fails', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertCreated(); + $response->assertJsonFragment([ + 'vultr_instance_id' => 'instance-polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + ]); + $this->assertDatabaseHas('servers', [ + 'name' => 'polling-fails', + 'ip' => Server::PLACEHOLDER_IP, + 'team_id' => $this->team->id, + 'vultr_instance_id' => 'instance-polling-fails', + 'vultr_instance_status' => 'pending', + ]); + Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); + }); + test('server creation authorizes access to the stored Vultr token', function () { $member = User::factory()->create(); $this->team->members()->attach($member->id, ['role' => 'member']); diff --git a/tests/Feature/VultrServerCreationTest.php b/tests/Feature/VultrServerCreationTest.php index d8e5af321..9474b697f 100644 --- a/tests/Feature/VultrServerCreationTest.php +++ b/tests/Feature/VultrServerCreationTest.php @@ -8,6 +8,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Livewire\Livewire; @@ -170,6 +171,56 @@ function vultrLivewireTestPublicKey(): string 'vultr_instance_id' => 'instance-1', 'vultr_instance_status' => 'pending', ]); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); +}); + +it('deletes the Vultr instance when local server persistence fails', function () { + Http::fake([ + 'https://api.vultr.com/v2/ssh-keys' => Http::response([ + 'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()], + ], 201), + 'https://api.vultr.com/v2/ssh-keys*' => Http::response([ + 'ssh_keys' => [], + 'meta' => ['links' => ['next' => null]], + ], 200), + 'https://api.vultr.com/v2/instances' => Http::response([ + 'instance' => [ + 'id' => 'instance-1', + 'label' => 'persistence-fails', + 'main_ip' => '192.0.2.10', + 'v6_main_ip' => '2001:db8::1', + 'status' => 'pending', + ], + ], 202), + 'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204), + ]); + + $eventDispatcher = Server::getEventDispatcher(); + Server::setEventDispatcher(clone $eventDispatcher); + Server::created(function (): void { + throw new RuntimeException('local persistence failed'); + }); + + try { + Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid]) + ->set('server_name', 'persistence-fails') + ->set('selected_region', 'ewr') + ->set('selected_plan', 'vc2-1c-1gb') + ->set('selected_os_id', 2284) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertDispatched('error', 'local persistence failed'); + } finally { + Server::setEventDispatcher($eventDispatcher); + } + + $this->assertDatabaseMissing('servers', [ + 'vultr_instance_id' => 'instance-1', + ]); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.vultr.com/v2/instances/instance-1'); }); it('requires IPv6 when public IPv4 is disabled', function () { diff --git a/tests/Unit/Api/LogEndpointHelpersTest.php b/tests/Unit/Api/LogEndpointHelpersTest.php index 0b0107e73..a899669d6 100644 --- a/tests/Unit/Api/LogEndpointHelpersTest.php +++ b/tests/Unit/Api/LogEndpointHelpersTest.php @@ -18,6 +18,20 @@ ->and(normalizeLogLines('50000'))->toBe(10000); }); +it('normalizes service resource log line counts before invoking Docker', function (string $controller, mixed $lines, int $expectedLines) { + $source = file_get_contents(__DIR__."/../../../app/Http/Controllers/Api/{$controller}.php"); + + expect($source)->toContain('$lines = normalizeLogLines($request->query(\'lines\'));') + ->and(normalizeLogLines($lines))->toBe($expectedLines); +})->with([ + 'application invalid value' => ['ServiceApplicationsController', 'invalid', 100], + 'application negative value' => ['ServiceApplicationsController', '-5', 100], + 'application value above limit' => ['ServiceApplicationsController', '50000', 10000], + 'database invalid value' => ['ServiceDatabasesController', 'invalid', 100], + 'database negative value' => ['ServiceDatabasesController', '-5', 100], + 'database value above limit' => ['ServiceDatabasesController', '50000', 10000], +]); + it('parses show_timestamps query values as booleans', function () { if (! function_exists('parseLogTimestampFlag')) { expect(function_exists('parseLogTimestampFlag'))->toBeTrue(); diff --git a/tests/Unit/DigitalOceanServiceTest.php b/tests/Unit/DigitalOceanServiceTest.php index 784926838..ad687b546 100644 --- a/tests/Unit/DigitalOceanServiceTest.php +++ b/tests/Unit/DigitalOceanServiceTest.php @@ -1,11 +1,59 @@ Http::response(['message' => $message], $status), + ]); + + $exception = null; + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('DigitalOcean API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unable to authenticate you'], + 'unprocessable entity' => [422, 'Droplet name is invalid'], +]); + +it('maps a final DigitalOcean rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets' => Http::sequence() + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Temporary provider error'], 500) + ->push(['message' => 'Too many requests'], 429, ['Retry-After' => '45']), + ]); + + try { + (new DigitalOceanService('test-token'))->createDroplet([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(45); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('fetches paginated regions from DigitalOcean', function () { Http::fake([ 'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([ diff --git a/tests/Unit/EmailIdentityHelperTest.php b/tests/Unit/EmailIdentityHelperTest.php index 475c51009..3b27f6ff6 100644 --- a/tests/Unit/EmailIdentityHelperTest.php +++ b/tests/Unit/EmailIdentityHelperTest.php @@ -1,8 +1,12 @@ toBe('kevinmcfadden@btinternet.com'); - expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@btinternet.com'))->toBe('kevinmcfadden@btinternet.com'); + expect(normalize_email_identity('Ke.VinMcFadden+one@Gmail.com'))->toBe('kevinmcfadden@gmail.com'); + expect(normalize_email_identity('k.e.v.i.n.m.c.f.a.d.d.e.n+two@googlemail.com'))->toBe('kevinmcfadden@googlemail.com'); +}); + +it('preserves dots and plus suffixes for ordinary email providers', function () { + expect(normalize_email_identity(' John.Smith+alerts@Example.com '))->toBe('john.smith+alerts@example.com'); }); it('returns null for blank or malformed email identities', function (?string $email) { diff --git a/tests/Unit/VultrServiceTest.php b/tests/Unit/VultrServiceTest.php index a912b8907..dc778e1b2 100644 --- a/tests/Unit/VultrServiceTest.php +++ b/tests/Unit/VultrServiceTest.php @@ -1,6 +1,8 @@ Http::response(['error' => $message], $status), + ]); + + $exception = null; + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (Throwable $caught) { + $exception = $caught; + } + + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception)->not->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('Vultr API error: '.$message) + ->and($exception->getCode())->toBe($status); + + Http::assertSentCount(3); +})->with([ + 'unauthorized' => [401, 'Unauthorized'], + 'unprocessable entity' => [422, 'Invalid instance parameters'], +]); + +it('maps a final Vultr rate-limit response with Retry-After', function () { + Http::fake([ + 'https://api.vultr.com/v2/instances' => Http::sequence() + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Temporary provider error'], 500) + ->push(['error' => 'Too many requests'], 429, ['Retry-After' => '30']), + ]); + + try { + (new VultrService('fake-token'))->createInstance([]); + } catch (RateLimitException $exception) { + expect($exception->getMessage())->toBe('Rate limit exceeded. Please try again later.') + ->and($exception->retryAfter)->toBe(30); + + Http::assertSentCount(3); + + return; + } + + test()->fail('Expected a RateLimitException to be thrown.'); +}); + it('gets instances from Vultr API', function () { Http::fake([ 'https://api.vultr.com/v2/instances*' => Http::response([