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.
This commit is contained in:
parent
2719d66042
commit
7d699818e8
15 changed files with 429 additions and 63 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,59 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('maps final DigitalOcean API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => 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([
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<?php
|
||||
|
||||
it('normalizes plus and dotted email identity variants', function () {
|
||||
expect(normalize_email_identity('Ke.VinMcFadden+one@BTInternet.com'))->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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -10,6 +12,52 @@
|
|||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('maps final Vultr API error responses', function (int $status, string $message) {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => 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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue