mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 12:14:07 +00:00
Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react
This commit is contained in:
commit
772093928f
241 files changed
+22765
-1087
No files matched your search
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'source-commit-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function sourceCommitSettingApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} include_source_commit_in_build', function () {
|
||||
test('updates the application setting through the API', function () {
|
||||
expect((bool) $this->application->settings->include_source_commit_in_build)->toBeFalse();
|
||||
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => true,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeTrue();
|
||||
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => false,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects non boolean values', function () {
|
||||
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'include_source_commit_in_build' => 'not-a-boolean',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('include_source_commit_in_build');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,12 @@ use Illuminate\Support\Once;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
@@ -78,6 +84,63 @@ describe('GET /api/v1/cloud-tokens', function () {
|
||||
$response = $this->getJson('/api/v1/cloud-tokens');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read token does not include provider token values', function () {
|
||||
CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Hidden Token',
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'hidden-cloud-provider-token',
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens');
|
||||
|
||||
$response->assertSuccessful();
|
||||
expect($response->getContent())->not->toContain('"token":');
|
||||
});
|
||||
|
||||
test('read sensitive token includes provider token values', function () {
|
||||
CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Visible Token',
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'visible-cloud-provider-token',
|
||||
]);
|
||||
|
||||
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readSensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token']);
|
||||
});
|
||||
|
||||
test('root token includes provider token values', function () {
|
||||
CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Root Visible Token',
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'root-visible-cloud-provider-token',
|
||||
]);
|
||||
|
||||
$rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$rootToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/cloud-tokens');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment(['token' => 'root-visible-cloud-provider-token']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/cloud-tokens/{uuid}', function () {
|
||||
@@ -120,6 +183,44 @@ describe('GET /api/v1/cloud-tokens/{uuid}', function () {
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('read token does not include provider token value by UUID', function () {
|
||||
$token = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Hidden Token Detail',
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'hidden-cloud-provider-token-detail',
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
expect($response->getContent())->not->toContain('"token":');
|
||||
});
|
||||
|
||||
test('read sensitive token includes provider token value by UUID', function () {
|
||||
$token = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Visible Token Detail',
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'visible-cloud-provider-token-detail',
|
||||
]);
|
||||
|
||||
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readSensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token-detail']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/cloud-tokens', function () {
|
||||
@@ -168,6 +269,30 @@ describe('POST /api/v1/cloud-tokens', function () {
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
});
|
||||
|
||||
test('creates a Vultr cloud provider token', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/account' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/cloud-tokens', [
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'My Vultr Token',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
|
||||
$this->assertDatabaseHas('cloud_provider_tokens', [
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'name' => 'My Vultr Token',
|
||||
]);
|
||||
});
|
||||
|
||||
test('validates provider is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
@@ -207,7 +332,7 @@ describe('POST /api/v1/cloud-tokens', function () {
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('validates provider must be hetzner or digitalocean', function () {
|
||||
test('validates provider must be hetzner digitalocean or vultr', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
@@ -294,8 +419,11 @@ describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid request.',
|
||||
'error' => 'Invalid JSON.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot update token from another team', function () {
|
||||
@@ -420,6 +548,25 @@ describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
|
||||
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
|
||||
});
|
||||
|
||||
test('validates a valid Vultr token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/account' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
|
||||
});
|
||||
|
||||
test('writes an audit log entry when validating a stored token', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
|
||||
@@ -1,31 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('cache.default', 'array');
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
// Create a team with owner
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a private key for the team
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => 'test-private-key-content',
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
function createGithubAppsApiToken($context, array $abilities): string
|
||||
{
|
||||
session(['currentTeam' => $context->team]);
|
||||
|
||||
return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
|
||||
*
|
||||
* Generated in-process so tests do not depend on external files or secrets;
|
||||
* the key is used only as a signing fixture and is never persisted.
|
||||
*
|
||||
* @return string PEM-encoded RSA private key
|
||||
*/
|
||||
function validGithubAppsApiPrivateKey(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
describe('GET /api/v1/github-apps', function () {
|
||||
test('returns 401 when not authenticated', function () {
|
||||
$response = $this->getJson('/api/v1/github-apps');
|
||||
@@ -71,7 +106,7 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not return sensitive data', function () {
|
||||
test('does not return sensitive data for read tokens', function () {
|
||||
// Create a GitHub app
|
||||
GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -86,8 +121,10 @@ describe('GET /api/v1/github-apps', function () {
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$readToken = createGithubAppsApiToken($this, ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
])->getJson('/api/v1/github-apps');
|
||||
|
||||
$response->assertStatus(200);
|
||||
@@ -98,6 +135,33 @@ describe('GET /api/v1/github-apps', function () {
|
||||
expect($json[0])->not->toHaveKey('webhook_secret');
|
||||
});
|
||||
|
||||
test('returns sensitive data for read sensitive tokens', function () {
|
||||
GithubApp::create([
|
||||
'name' => 'Sensitive GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'secret-should-be-visible',
|
||||
'webhook_secret' => 'webhook-secret-should-be-visible',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
])->getJson('/api/v1/github-apps');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'client_secret' => 'secret-should-be-visible',
|
||||
'webhook_secret' => 'webhook-secret-should-be-visible',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns system-wide github apps', function () {
|
||||
// Create a system-wide GitHub app
|
||||
$systemApp = GithubApp::create([
|
||||
@@ -118,7 +182,8 @@ describe('GET /api/v1/github-apps', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$otherToken = $otherUser->createToken('other-token', ['*']);
|
||||
|
||||
// System-wide apps should be visible to other teams
|
||||
$response = $this->withHeaders([
|
||||
@@ -152,7 +217,7 @@ describe('GET /api/v1/github-apps', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherPrivateKey = PrivateKey::create([
|
||||
'name' => 'Other Key',
|
||||
'private_key' => 'other-key',
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
GithubApp::create([
|
||||
@@ -220,3 +285,179 @@ describe('GET /api/v1/github-apps', function () {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub app API url normalization', function () {
|
||||
test('normalizes ghe dot com api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://api.github.ghe.com',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves provided api url when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => '/octocorp/',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment([
|
||||
'organization' => 'octocorp',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves provided api url when updating github apps', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://github.company.internal/api/v3',
|
||||
'html_url' => 'https://github.company.internal',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'html_url' => 'https://github.com',
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
|
||||
});
|
||||
|
||||
test('preserves provided api url when updating api url only', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
|
||||
'api_url' => 'https://github.com/api/v3',
|
||||
]);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
|
||||
});
|
||||
|
||||
test('rejects invalid organization when creating github apps', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson('/api/v1/github-apps', [
|
||||
'name' => 'GHE App',
|
||||
'organization' => 'octo/corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['organization']);
|
||||
});
|
||||
|
||||
test('loads repositories and branches through normalized ghe dot com api url', function () {
|
||||
$this->privateKey->update([
|
||||
'private_key' => validGithubAppsApiPrivateKey(),
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'GHE App',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'client_id' => 'test-client-id',
|
||||
'client_secret' => 'test-client-secret',
|
||||
'webhook_secret' => 'test-webhook-secret',
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
|
||||
'token' => 'installation-token',
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
|
||||
'repositories' => [
|
||||
['name' => 'repo', 'full_name' => 'octocorp/repo'],
|
||||
],
|
||||
]),
|
||||
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
|
||||
['name' => 'main'],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('repositories.0.name', 'repo');
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('branches.0.name', 'main');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
|
||||
});
|
||||
});
|
||||
@@ -6,14 +6,22 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as HttpRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('cache.default', 'array');
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings = new InstanceSettings([
|
||||
'is_api_enabled' => true,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
@@ -29,15 +37,25 @@ beforeEach(function () {
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Create a Hetzner cloud provider token
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
$this->hetznerToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Test Hetzner Token',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
// Create a private key
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -239,17 +257,105 @@ describe('GET /api/v1/hetzner/ssh-keys', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/firewalls', function () {
|
||||
test('gets Hetzner firewalls', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
['id' => 39, 'name' => 'ssh-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'web-firewall']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [['id' => 38, 'name' => 'web-firewall']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/hetzner/networks', function () {
|
||||
test('gets Hetzner networks', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
['id' => 456, 'name' => 'private-eu', 'ip_range' => '10.0.0.0/16', 'subnets' => []],
|
||||
['id' => 457, 'name' => 'private-us', 'ip_range' => '10.1.0.0/16', 'subnets' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'private-eu']);
|
||||
});
|
||||
|
||||
test('member read token cannot use a stored cloud provider token', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [['id' => 456, 'name' => 'private-eu']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$memberToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/hetzner', function () {
|
||||
test('creates a Hetzner server', function () {
|
||||
// Mock Hetzner API calls
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
@@ -289,15 +395,134 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
test('enables backups after creating a Hetzner server when requested', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => true,
|
||||
'enable_ipv6' => true,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup');
|
||||
});
|
||||
|
||||
test('registers server when backup enablement fails after Hetzner creation', function () {
|
||||
Http::fake(function (HttpRequest $request) {
|
||||
if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) {
|
||||
return Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') {
|
||||
return Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') {
|
||||
return Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'name' => 'test-server',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') {
|
||||
return Http::response([
|
||||
'error' => ['message' => 'backup unavailable'],
|
||||
], 500);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => true,
|
||||
'enable_ipv6' => true,
|
||||
'enable_backups' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['hetzner_server_id' => 456, 'ip' => '1.2.3.4']);
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'team_id' => $this->team->id,
|
||||
'hetzner_server_id' => 456,
|
||||
]);
|
||||
});
|
||||
|
||||
test('generates server name if not provided', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
@@ -331,13 +556,10 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors([
|
||||
'cloud_provider_token_id',
|
||||
'location',
|
||||
'server_type',
|
||||
'image',
|
||||
'private_key_uuid',
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid request.',
|
||||
'error' => 'Invalid JSON.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -375,13 +597,13 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
|
||||
test('prefers IPv4 when both IPv4 and IPv6 are enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
@@ -412,13 +634,13 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
|
||||
test('uses IPv6 when only IPv6 is enabled', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
@@ -447,6 +669,75 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('rejects server creation when both public IP protocols are disabled', function () {
|
||||
Http::fake();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv4' => false,
|
||||
'enable_ipv6' => false,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes selected firewalls and networks to Hetzner server creation', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123],
|
||||
], 201),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||
'server' => [
|
||||
'id' => 456,
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '1.2.3.4'],
|
||||
'ipv6' => ['ip' => '2001:db8::1'],
|
||||
],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/hetzner', [
|
||||
'cloud_provider_token_id' => $this->hetznerToken->uuid,
|
||||
'location' => 'nbg1',
|
||||
'server_type' => 'cx11',
|
||||
'image' => 15512617,
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'hetzner_firewall_ids' => [38, 39],
|
||||
'hetzner_network_ids' => [456, 457, 456],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
Http::assertSent(function (HttpRequest $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://api.hetzner.cloud/v1/servers'
|
||||
&& $request['networks'] === [456, 457]
|
||||
&& $request['firewalls'] === [
|
||||
['firewall' => 38],
|
||||
['firewall' => 39],
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
@@ -545,4 +836,38 @@ describe('error responses do not leak exception details', function () {
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('firewalls endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/firewalls?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner firewalls.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('networks endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/networks?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner networks.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,21 @@ describe('GitHub Source Change Component', function () {
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
|
||||
});
|
||||
|
||||
test('ghe.com installation path encodes plus signs in the organization segment', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'id' => 123,
|
||||
'name' => 'Provided GitHub App',
|
||||
'organization' => 'octo+corp',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'team_id' => 456,
|
||||
]);
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
|
||||
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
|
||||
});
|
||||
|
||||
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
@@ -404,6 +419,49 @@ describe('GitHub Source Change Component', function () {
|
||||
expect($githubApp->private_key_id)->toBe($privateKey->id);
|
||||
});
|
||||
|
||||
test('preserves custom ghe api url when saving github app settings', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://github.ghe.com/api/v3',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('htmlUrl', 'https://github.ghe.com')
|
||||
->set('apiUrl', 'https://github.ghe.com/api/v3')
|
||||
->call('submit')
|
||||
->assertDispatched('success')
|
||||
->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
|
||||
|
||||
$githubApp->refresh();
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
});
|
||||
|
||||
test('rejects invalid github organization values', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->set('organization', 'octo/corp')
|
||||
->call('submit')
|
||||
->assertHasErrors(['organization']);
|
||||
});
|
||||
|
||||
test('validation allows nullable values for app configuration', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
@@ -556,4 +614,43 @@ describe('GitHub Source Change Component', function () {
|
||||
->and($githubApp->metadata)->toBe('read')
|
||||
->and($githubApp->pull_requests)->toBe('write');
|
||||
});
|
||||
|
||||
test('sync name uses normalized resolvable ghe dot com api url', function () {
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'Test Key',
|
||||
'private_key' => validPrivateKey(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'api_url' => 'https://api.github.ghe.com',
|
||||
'html_url' => 'https://github.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'app_id' => 12345,
|
||||
'installation_id' => 67890,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
]);
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
'date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.ghe.com/app' => Http::response([
|
||||
'slug' => 'octocorp-app',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
|
||||
->test(Change::class)
|
||||
->assertSuccessful()
|
||||
->call('updateGithubAppName')
|
||||
->assertDispatched('success');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
|
||||
});
|
||||
});
|
||||
@@ -72,8 +72,8 @@ describe('GitHub Source Create Component', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
->set('name', 'enterprise-app')
|
||||
->set('api_url', 'https://github.enterprise.com/api/v3')
|
||||
->set('html_url', 'https://github.enterprise.com')
|
||||
->set('api_url', 'https://github.ghe.com/api/v3')
|
||||
->set('html_url', 'https://github.ghe.com')
|
||||
->set('custom_user', 'git-custom')
|
||||
->set('custom_port', 2222)
|
||||
->call('createGitHubApp')
|
||||
@@ -82,12 +82,28 @@ describe('GitHub Source Create Component', function () {
|
||||
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
|
||||
|
||||
expect($githubApp)->not->toBeNull();
|
||||
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.ghe.com');
|
||||
expect($githubApp->custom_user)->toBe('git-custom');
|
||||
expect($githubApp->custom_port)->toBe(2222);
|
||||
});
|
||||
|
||||
test('preserves custom github enterprise api url when creating github app', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
->set('name', 'ghe-custom-api-app')
|
||||
->set('api_url', 'https://github.ghe.com/api/v3')
|
||||
->set('html_url', 'https://github.ghe.com')
|
||||
->call('createGitHubApp')
|
||||
->assertRedirect();
|
||||
|
||||
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
|
||||
|
||||
expect($githubApp)->not->toBeNull();
|
||||
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
|
||||
expect($githubApp->html_url)->toBe('https://github.ghe.com');
|
||||
});
|
||||
|
||||
test('validates required fields', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertSuccessful()
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use phpseclib3\Crypt\EC;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team);
|
||||
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$ecKey = EC::createKey('Ed25519');
|
||||
$privateKeyContent = $ecKey->toString('OpenSSH');
|
||||
|
||||
$privateKey = PrivateKey::create([
|
||||
'name' => 'test-key',
|
||||
'private_key' => $privateKeyContent,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
ServerSetting::create([
|
||||
'server_id' => $this->server->id,
|
||||
'wildcard_domain' => 'http://127.0.0.1.sslip.io',
|
||||
]);
|
||||
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'test-network-'.fake()->uuid(),
|
||||
]);
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toBeArray()
|
||||
->and($domains)->toHaveKey('backend')
|
||||
->and($domains['backend'])->toHaveKey('domain')
|
||||
->and($domains['backend']['domain'])->toStartWith('http://')
|
||||
->and($domains['backend']['domain'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toHaveKey('frontend')
|
||||
->and($domains['frontend'])->toHaveKey('domain');
|
||||
});
|
||||
|
||||
test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockerfile',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
// For non-dockercompose, docker_compose_domains should remain empty
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
expect($domains)->toBeNull();
|
||||
});
|
||||
|
||||
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_URL_FRONTEND=/ui
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => null,
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains)->not->toBeNull()
|
||||
->and($domains)->toHaveKey('frontend')
|
||||
->and($domains['frontend']['domain'])->toStartWith('http://')
|
||||
->and($domains['frontend']['domain'])->toEndWith('/ui');
|
||||
});
|
||||
|
||||
test('applicationParser preserves existing docker_compose_domains entries', function () {
|
||||
$dockerCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
environment:
|
||||
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
- SERVICE_URL_FRONTEND=/ui
|
||||
YAML;
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => json_encode([
|
||||
'frontend' => ['domain' => 'https://existing.example.com'],
|
||||
]),
|
||||
]);
|
||||
|
||||
applicationParser($application);
|
||||
|
||||
$application->refresh();
|
||||
|
||||
$domains = json_decode($application->docker_compose_domains, true);
|
||||
|
||||
expect($domains['frontend']['domain'])->toBe('https://existing.example.com')
|
||||
->and($domains)->toHaveKey('backend')
|
||||
->and($domains['backend']['domain'])->toStartWith('http://');
|
||||
});
|
||||
|
||||
test('applicationParser handles other docker compose domain shapes without regressions', function () {
|
||||
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
|
||||
return Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'build_pack' => 'dockercompose',
|
||||
'docker_compose_raw' => $dockerCompose,
|
||||
'fqdn' => null,
|
||||
'docker_compose_domains' => $dockerComposeDomains,
|
||||
]);
|
||||
};
|
||||
|
||||
$valueBasedCompose = <<<'YAML'
|
||||
services:
|
||||
backend:
|
||||
image: myapp/backend:latest
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
API_URL: ${SERVICE_URL_BACKEND}
|
||||
YAML;
|
||||
|
||||
$valueBasedApplication = $createApplication($valueBasedCompose);
|
||||
applicationParser($valueBasedApplication);
|
||||
$valueBasedApplication->refresh();
|
||||
$valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true);
|
||||
|
||||
expect($valueBasedDomains)->toHaveKey('backend')
|
||||
->and($valueBasedDomains['backend']['domain'])->toStartWith('http://')
|
||||
->and($valueBasedDomains)->not->toHaveKey('frontend');
|
||||
|
||||
$mapStyleCompose = <<<'YAML'
|
||||
services:
|
||||
frontend:
|
||||
image: myapp/frontend:latest
|
||||
environment:
|
||||
SERVICE_URL_FRONTEND: /ui
|
||||
YAML;
|
||||
|
||||
$mapStyleApplication = $createApplication($mapStyleCompose);
|
||||
applicationParser($mapStyleApplication);
|
||||
$mapStyleApplication->refresh();
|
||||
$mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true);
|
||||
|
||||
expect($mapStyleDomains)->toHaveKey('frontend')
|
||||
->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui');
|
||||
|
||||
$missingServiceCompose = <<<'YAML'
|
||||
services:
|
||||
worker:
|
||||
image: myapp/worker:latest
|
||||
environment:
|
||||
SERVICE_URL_API: /api
|
||||
YAML;
|
||||
|
||||
$missingServiceApplication = $createApplication($missingServiceCompose);
|
||||
applicationParser($missingServiceApplication);
|
||||
$missingServiceApplication->refresh();
|
||||
|
||||
expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull();
|
||||
|
||||
$plainCompose = <<<'YAML'
|
||||
services:
|
||||
worker:
|
||||
image: myapp/worker:latest
|
||||
environment:
|
||||
FOO: bar
|
||||
YAML;
|
||||
|
||||
$plainApplication = $createApplication($plainCompose);
|
||||
applicationParser($plainApplication);
|
||||
$plainApplication->refresh();
|
||||
|
||||
expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
StandaloneDocker::withoutEvents(function () {
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrCreate(
|
||||
['network' => 'coolify'],
|
||||
['uuid' => (string) new Cuid2, 'name' => 'test-docker']
|
||||
);
|
||||
});
|
||||
|
||||
$this->project = Project::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'test-project',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function previewDeploymentsAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/applications/{uuid} is_preview_deployments_enabled', function () {
|
||||
test('can enable preview deployments', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => false]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('can disable preview deployments', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => false,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('is_preview_deployments_enabled is not changed when not in request', function () {
|
||||
$this->application->settings->update(['is_preview_deployments_enabled' => true]);
|
||||
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'name' => 'updated-name',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->application->refresh();
|
||||
expect($this->application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects non-boolean value', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_preview_deployments_enabled' => 'yes',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/public is_preview_deployments_enabled', function () {
|
||||
test('can enable preview deployments on create', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'is_preview_deployments_enabled' => true,
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
expect($application->settings->is_preview_deployments_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects non-boolean value on create', function () {
|
||||
$response = $this->withHeaders(previewDeploymentsAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-preview-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'is_preview_deployments_enabled' => 'yes',
|
||||
'autogenerate_domain' => false,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'public-git-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createPublicGitApplication(array $overrides = []): Application
|
||||
{
|
||||
$response = test()->withHeaders([
|
||||
'Authorization' => 'Bearer '.test()->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/applications/public', array_merge([
|
||||
'project_uuid' => test()->project->uuid,
|
||||
'environment_uuid' => test()->environment->uuid,
|
||||
'server_uuid' => test()->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'autogenerate_domain' => false,
|
||||
], $overrides));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
return Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
}
|
||||
|
||||
test('public non github repositories keep their full git repository url', function () {
|
||||
$application = createPublicGitApplication([
|
||||
'git_repository' => 'https://cnb.cool/matridx/frp_webui',
|
||||
]);
|
||||
|
||||
expect($application->git_repository)->toBe('https://cnb.cool/matridx/frp_webui')
|
||||
->and($application->source_type)->toBeNull()
|
||||
->and($application->source_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('public github repositories are stored as owner and repository with the public github source', function () {
|
||||
GithubApp::unguarded(fn () => GithubApp::create([
|
||||
'id' => 0,
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'team_id' => 0,
|
||||
]));
|
||||
|
||||
$application = createPublicGitApplication([
|
||||
'git_repository' => 'https://github.com/coollabsio/coolify-examples',
|
||||
]);
|
||||
|
||||
expect($application->git_repository)->toBe('coollabsio/coolify-examples')
|
||||
->and($application->source_type)->toBe(GithubApp::class)
|
||||
->and($application->source_id)->toBe(0);
|
||||
});
|
||||
@@ -81,7 +81,7 @@ test('admin sees add server button on dashboard', function () {
|
||||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('New Server');
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('member does not see add server button on dashboard', function () {
|
||||
@@ -93,5 +93,5 @@ test('member does not see add server button on dashboard', function () {
|
||||
createServerWithKeyForTeam($team);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertDontSee('New Server');
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
@@ -15,7 +15,11 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||
use App\Livewire\Server\Create as ServerCreate;
|
||||
use App\Livewire\Server\Index as ServerIndex;
|
||||
use App\Livewire\Server\Navbar as ServerNavbar;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -132,6 +135,144 @@ test('member cannot create server', function () {
|
||||
expect(auth()->user()->can('create', Server::class))->toBeFalse();
|
||||
});
|
||||
|
||||
test('admin can access new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertSuccessful()
|
||||
->assertSee('New Server')
|
||||
->assertSeeLivewire(ServerCreate::class);
|
||||
});
|
||||
|
||||
test('new server chooser lists providers before rendering a creation form', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('Continue')
|
||||
->assertSee(route('server.create.type', ['type' => 'hetzner']))
|
||||
->assertSee(route('server.create.type', ['type' => 'vultr']))
|
||||
->assertSee(route('server.create.type', ['type' => 'digital-ocean']))
|
||||
->assertSee(route('server.create.type', ['type' => 'manual']))
|
||||
->assertDontSee('Add Server by IP Address');
|
||||
});
|
||||
|
||||
test('new server chooser uses compact mobile provider cards', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerCreate::class)
|
||||
->assertSee('mx-auto flex w-full max-w-7xl flex-col', false)
|
||||
->assertSee('sm:grid', false)
|
||||
->assertSee('sm:grid-cols-2', false)
|
||||
->assertSee('xl:grid-cols-4', false)
|
||||
->assertSee('gap-3 sm:gap-6', false)
|
||||
->assertSee('dark:hover:border-warning', false)
|
||||
->assertSee('focus-visible:border-coollabs', false)
|
||||
->assertSee('aria-label="Choose Hetzner"', false)
|
||||
->assertSee('p-3 sm:p-6', false)
|
||||
->assertDontSee('sm:min-h-80', false)
|
||||
->assertSee('size-9 sm:size-14', false)
|
||||
->assertDontSee('size-9 sm:size-14 w-14 h-14', false)
|
||||
->assertSee('hidden sm:block', false)
|
||||
->assertDontSee('>Select<', false)
|
||||
->assertDontSee('<button', false);
|
||||
});
|
||||
|
||||
test('new server provider pages render the selected creation flow', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($heading)
|
||||
->assertDontSee('<h1>New Server</h1>', false)
|
||||
->assertDontSee('Choose how to add your server');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
['manual', 'Manual'],
|
||||
]);
|
||||
|
||||
test('new server provider pages do not show the new token action in the header', function (string $type, string $heading) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertDontSee('+ New Token')
|
||||
->assertDontSee('+ Add New Token');
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back']);
|
||||
})->with([
|
||||
['hetzner', 'Hetzner'],
|
||||
['vultr', 'Vultr'],
|
||||
['digital-ocean', 'DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('new server provider pages show the new token action in the header when tokens exist', function (string $type, string $provider, string $heading, string $modalTitle) {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('server.create.type', ['type' => $type]))
|
||||
->assertSuccessful()
|
||||
->assertSee($modalTitle);
|
||||
|
||||
$response->assertSeeInOrder([$heading, 'Back', '+ New Token']);
|
||||
})->with([
|
||||
['hetzner', 'hetzner', 'Hetzner', 'Add Hetzner Token'],
|
||||
['vultr', 'vultr', 'Vultr', 'Add Vultr Token'],
|
||||
['digital-ocean', 'digitalocean', 'DigitalOcean', 'Add DigitalOcean Token'],
|
||||
]);
|
||||
|
||||
test('new server manual page does not show the new token action', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create.type', ['type' => 'manual']))
|
||||
->assertSuccessful()
|
||||
->assertSee('Manual')
|
||||
->assertDontSee('+ New Token');
|
||||
});
|
||||
|
||||
test('member cannot access new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->get(route('server.create'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('server index links admins to new server page', function () {
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertSee(route('server.create'));
|
||||
});
|
||||
|
||||
test('server index does not link members to new server page', function () {
|
||||
$this->actingAs($this->member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(ServerIndex::class)
|
||||
->assertDontSee(route('server.create'));
|
||||
});
|
||||
|
||||
// --- Cross-team isolation ---
|
||||
|
||||
test('user from different team cannot view server', function () {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows link cloud provider dropdown with available unlinked providers', function () {
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'Test DigitalOcean Token',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('Hetzner Token')
|
||||
->assertSee('DigitalOcean Token')
|
||||
->assertSee('Vultr Token')
|
||||
->assertSee('Server ID')
|
||||
->assertSee('Droplet ID')
|
||||
->assertSee('Instance ID')
|
||||
->assertSee('Search by IP')
|
||||
->assertSee('Search');
|
||||
});
|
||||
|
||||
it('hides link cloud provider dropdown when no providers can be linked', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertDontSee('Link Cloud Provider');
|
||||
});
|
||||
|
||||
it('does not list providers already linked to the server', function () {
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-token',
|
||||
'name' => 'Test Hetzner Token',
|
||||
]);
|
||||
|
||||
CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
$this->server->update(['hetzner_server_id' => 123]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Link Cloud Provider')
|
||||
->assertSee('Vultr Token')
|
||||
->assertDontSee('Hetzner Token');
|
||||
});
|
||||
|
||||
it('shows Hetzner search by IP errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'invalid-hetzner-token',
|
||||
'name' => 'Invalid Hetzner Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'error' => ['message' => 'invalid token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedHetznerTokenId', $token->id)
|
||||
->call('searchHetznerServer')
|
||||
->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to search Hetzner servers:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search Hetzner servers:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows Hetzner search by ID errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'invalid-hetzner-token',
|
||||
'name' => 'Invalid Hetzner Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers/12345678' => Http::response([
|
||||
'error' => ['message' => 'invalid token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedHetznerTokenId', $token->id)
|
||||
->set('manualHetznerServerId', '12345678')
|
||||
->call('searchHetznerServerById')
|
||||
->assertSet('hetznerSearchError', fn (string $error) => str_contains($error, 'Failed to fetch Hetzner server:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to fetch Hetzner server:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows DigitalOcean search errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'invalid-digitalocean-token',
|
||||
'name' => 'Invalid DigitalOcean Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets*' => Http::response([
|
||||
'message' => 'invalid token',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedDigitalOceanTokenId', $token->id)
|
||||
->call('searchDigitalOceanDroplet')
|
||||
->assertSet('digitalOceanSearchError', fn (string $error) => str_contains($error, 'Failed to search DigitalOcean droplets:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search DigitalOcean droplets:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
|
||||
it('shows Vultr search errors in the modal', function () {
|
||||
$token = CloudProviderToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'invalid-vultr-token',
|
||||
'name' => 'Invalid Vultr Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
'error' => 'invalid token',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('selectedVultrTokenId', $token->id)
|
||||
->call('searchVultrInstance')
|
||||
->assertSet('vultrSearchError', fn (string $error) => str_contains($error, 'Failed to search Vultr instances:') && str_contains($error, 'invalid token'))
|
||||
->assertSee('Failed to search Vultr instances:')
|
||||
->assertSee('invalid token');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\CloudProviderToken\Show;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('server cloud provider token cards show token descriptions', function () {
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'hetzner_server_id' => 12345,
|
||||
]);
|
||||
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Production Hetzner',
|
||||
'description' => 'Used for production servers in the EU region.',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $server->uuid])
|
||||
->assertSee('Production Hetzner')
|
||||
->assertSee('Used for production servers in the EU region.');
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Regression test: EnvironmentVariable hides `value` in serialized output, so the
|
||||
* config hash must makeVisible('value') before json_encode — otherwise editing an
|
||||
* env var value no longer flips the hash and no restart is suggested.
|
||||
*/
|
||||
it('detects environment variable value change in database config hash', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'config-hash-db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => encrypt('password'),
|
||||
'postgres_db' => 'app',
|
||||
'image' => 'postgres:16-alpine',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$variable = $database->environment_variables()->create([
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'original-value',
|
||||
]);
|
||||
|
||||
$database->isConfigurationChanged(save: true);
|
||||
expect($database->refresh()->isConfigurationChanged())->toBeFalse();
|
||||
|
||||
$variable->update(['value' => 'changed-value']);
|
||||
|
||||
expect($database->refresh()->isConfigurationChanged())->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->whereKey(0)->delete();
|
||||
$settings = new InstanceSettings(['is_api_enabled' => true]);
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->digitalOceanToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-api-token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('GET /api/v1/digitalocean/regions', function () {
|
||||
test('gets DigitalOcean regions', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/regions*' => Http::response([
|
||||
'regions' => [
|
||||
['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true],
|
||||
['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true],
|
||||
],
|
||||
'links' => ['pages' => []],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/digitalocean/regions?cloud_provider_token_id='.$this->digitalOceanToken->uuid);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['slug' => 'nyc1']);
|
||||
});
|
||||
|
||||
test('requires cloud_provider_token_id parameter', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/digitalocean/regions');
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/digitalocean', function () {
|
||||
test('creates a DigitalOcean droplet server', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||
'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'links' => ['pages' => []],
|
||||
], 200),
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'name' => 'test-server',
|
||||
'status' => 'new',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
'v6' => [
|
||||
['ip_address' => '2001:db8::10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/digitalocean', [
|
||||
'cloud_provider_token_id' => $this->digitalOceanToken->uuid,
|
||||
'region' => 'nyc1',
|
||||
'size' => 's-1vcpu-1gb',
|
||||
'image' => 'ubuntu-24-04-x64',
|
||||
'name' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv6' => true,
|
||||
'monitoring' => true,
|
||||
'cloud_init_script' => '#cloud-config',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'digitalocean_droplet_id', 'ip']);
|
||||
$response->assertJsonFragment(['digitalocean_droplet_id' => 987, 'ip' => '203.0.113.10']);
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'ip' => '203.0.113.10',
|
||||
'team_id' => $this->team->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets'
|
||||
&& $request['region'] === 'nyc1'
|
||||
&& $request['size'] === 's-1vcpu-1gb'
|
||||
&& $request['image'] === 'ubuntu-24-04-x64'
|
||||
&& $request['ssh_keys'] === [123]
|
||||
&& $request['ipv6'] === true
|
||||
&& $request['monitoring'] === true
|
||||
&& $request['user_data'] === '#cloud-config');
|
||||
});
|
||||
|
||||
test('validates required fields', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/digitalocean', ['name' => 'missing-required-fields']);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors([
|
||||
'cloud_provider_token_id',
|
||||
'region',
|
||||
'size',
|
||||
'image',
|
||||
'private_key_uuid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns 404 for a non-DigitalOcean token', function () {
|
||||
$hetznerToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/digitalocean', [
|
||||
'cloud_provider_token_id' => $hetznerToken->uuid,
|
||||
'region' => 'nyc1',
|
||||
'size' => 's-1vcpu-1gb',
|
||||
'image' => 'ubuntu-24-04-x64',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
$response->assertJson(['message' => 'DigitalOcean cloud provider token not found.']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||
Livewire::test(ByDigitalOcean::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate(['id' => 0]);
|
||||
});
|
||||
|
||||
it('rate limits repeated forgot password attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => "user{$attempt}@example.com",
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.20'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'blocked@example.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 30)])
|
||||
->post('/forgot-password', [
|
||||
'email' => $email,
|
||||
])
|
||||
->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/forgot-password', [
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
@@ -82,6 +82,13 @@ function githubPrivateRepositoryTestPrivateKeyForTeam(Team $team): PrivateKey
|
||||
}
|
||||
|
||||
describe('GitHub Private Repository Component', function () {
|
||||
test('github app selection uses coolbox loading border', function () {
|
||||
Livewire::test(GithubPrivateRepository::class, ['type' => 'private-gh-app'])
|
||||
->assertSee('wire:loading.class="coolbox-loading"', false)
|
||||
->assertSee('wire:target="loadRepositories('.$this->githubApp->id.')"', false)
|
||||
->assertDontSee('loading-spinner', false);
|
||||
});
|
||||
|
||||
test('loadRepositories fetches and displays repositories', function () {
|
||||
$repos = [
|
||||
['id' => 1, 'name' => 'alpha-repo', 'owner' => ['login' => 'testuser']],
|
||||
|
||||
@@ -143,4 +143,107 @@ it('keeps configuration sidebars hidden until desktop breakpoint', function () {
|
||||
|
||||
expect(file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')))
|
||||
->toContain('sub-menu-wrapper hidden md:flex');
|
||||
|
||||
$serverSidebar = file_get_contents(resource_path('views/components/server/sidebar.blade.php'));
|
||||
|
||||
expect($serverSidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="Server menu"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('<optgroup label="Configuration">')
|
||||
->toContain("'route' => 'server.proxy'")
|
||||
->toContain("'navigate' => false")
|
||||
->toContain("value.startsWith('location|')")
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
|
||||
$serverNavbar = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
|
||||
|
||||
expect($serverNavbar)
|
||||
->toContain('server-mobile-actions')
|
||||
->toContain('Actions')
|
||||
->toContain('pb-0 md:pb-6')
|
||||
->toContain('navbar-main')
|
||||
->toContain('server-mobile-actions" class="mt-2 mb-3')
|
||||
->toContain('hidden min-h-10')
|
||||
->toContain('md:flex')
|
||||
->toContain('md:hidden')
|
||||
->toContain('flex flex-nowrap gap-2 overflow-x-auto')
|
||||
->toContain('server-mobile-restart-proxy-trigger')
|
||||
->toContain('server-mobile-stop-proxy-trigger')
|
||||
->toContain('class="button shrink-0"')
|
||||
->toContain('hidden gap-2 md:flex');
|
||||
|
||||
$serverMobileActions = serverMobileActionsMarkup($serverNavbar);
|
||||
|
||||
expect(strpos($serverMobileActions, 'Restart Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Stop Proxy'))
|
||||
->toBeLessThan(strpos($serverMobileActions, 'Traefik Dashboard'));
|
||||
|
||||
foreach ([
|
||||
'views/components/server/sidebar-proxy.blade.php' => 'Proxy menu',
|
||||
'views/components/server/sidebar-sentinel.blade.php' => 'Sentinel menu',
|
||||
'views/components/server/sidebar-security.blade.php' => 'Security menu',
|
||||
] as $view => $label) {
|
||||
$sidebar = file_get_contents(resource_path($view));
|
||||
|
||||
expect($sidebar)
|
||||
->toContain('server-mobile-section-label')
|
||||
->toContain('server-mobile-section')
|
||||
->toContain('Section')
|
||||
->toContain('select w-full')
|
||||
->toContain('aria-label="'.$label.'"')
|
||||
->toContain('border-b-2 border-solid border-neutral-200 pb-4 md:hidden dark:border-coolgray-200')
|
||||
->toContain('<optgroup label="Server">')
|
||||
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
|
||||
->toContain('x-on:livewire:navigated.window="syncFromLocation()"')
|
||||
->toContain('sub-menu-wrapper hidden md:flex')
|
||||
->not->toContain('sub-menu-wrapper">');
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'views/livewire/server/show.blade.php',
|
||||
'views/livewire/server/advanced.blade.php',
|
||||
'views/livewire/server/private-key/show.blade.php',
|
||||
'views/livewire/server/ca-certificate/show.blade.php',
|
||||
'views/livewire/server/cloud-provider-token/show.blade.php',
|
||||
'views/livewire/server/cloudflare-tunnel.blade.php',
|
||||
'views/livewire/server/docker-cleanup.blade.php',
|
||||
'views/livewire/server/destinations.blade.php',
|
||||
'views/livewire/server/log-drains.blade.php',
|
||||
'views/livewire/server/charts.blade.php',
|
||||
'views/livewire/server/swarm.blade.php',
|
||||
'views/livewire/server/delete.blade.php',
|
||||
'views/livewire/server/proxy/show.blade.php',
|
||||
'views/livewire/server/proxy/logs.blade.php',
|
||||
'views/livewire/server/proxy/dynamic-configurations.blade.php',
|
||||
'views/livewire/server/sentinel/show.blade.php',
|
||||
'views/livewire/server/sentinel/logs.blade.php',
|
||||
'views/livewire/server/security/patches.blade.php',
|
||||
'views/livewire/server/security/terminal-access.blade.php',
|
||||
] as $view) {
|
||||
expect(file_get_contents(resource_path($view)))
|
||||
->toContain('gap-4 md:gap-8 md:flex-row')
|
||||
->toContain('md:flex-row')
|
||||
->not->toContain('sm:flex-row')
|
||||
->not->toContain('class="flex flex-col h-full gap-8 md:flex-row');
|
||||
}
|
||||
});
|
||||
|
||||
function serverMobileActionsMarkup(string $navbar): string
|
||||
{
|
||||
$actionsPosition = strpos($navbar, 'id="server-mobile-actions"');
|
||||
$restartModalPosition = strpos($navbar, '<x-modal-confirmation', $actionsPosition);
|
||||
|
||||
if ($actionsPosition === false || $restartModalPosition === false || $actionsPosition > $restartModalPosition) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($navbar, $actionsPosition, $restartModalPosition - $actionsPosition);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ it('creates Server with all fillable attributes', function () {
|
||||
'team_id' => $this->team->id,
|
||||
'hetzner_server_id' => 'htz-12345',
|
||||
'hetzner_server_status' => 'running',
|
||||
'digitalocean_droplet_id' => 987654,
|
||||
'digitalocean_droplet_status' => 'active',
|
||||
'is_validating' => false,
|
||||
'detected_traefik_version' => 'v2.10.0',
|
||||
'traefik_outdated_info' => 'Up to date',
|
||||
@@ -101,6 +103,8 @@ it('creates Server with all fillable attributes', function () {
|
||||
expect($server->cloud_provider_token_id)->toBe($cloudToken->id);
|
||||
expect($server->hetzner_server_id)->toBe('htz-12345');
|
||||
expect($server->hetzner_server_status)->toBe('running');
|
||||
expect($server->digitalocean_droplet_id)->toBe(987654);
|
||||
expect($server->digitalocean_droplet_status)->toBe('active');
|
||||
expect($server->ip_previous)->toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first();
|
||||
|
||||
$this->targetProject = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->targetEnvironment = $this->targetProject->environments()->first();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/move', function () {
|
||||
test('moves application to another environment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Application moved successfully.']);
|
||||
$response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('writes audit log when application is moved', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('api.application.moved', Mockery::on(function (array $context) use ($application) {
|
||||
return $context['event'] === 'api.application.moved'
|
||||
&& $context['resource_uuid'] === $application->uuid
|
||||
&& $context['resource_type'] === 'application'
|
||||
&& $context['from_environment_uuid'] === $this->environment->uuid
|
||||
&& $context['to_environment_uuid'] === $this->targetEnvironment->uuid
|
||||
&& $context['from_project_uuid'] === $this->project->uuid
|
||||
&& $context['to_project_uuid'] === $this->targetProject->uuid;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$request = Request::create('/', 'POST', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
test('returns 404 when application not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/applications/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 422 when environment_uuid is missing', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 when extra fields are provided', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
'bogus_field' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 404 when target environment belongs to another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $otherEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('authorizes the target environment before moving', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
Gate::before(function (User $user, string $ability, array $arguments) {
|
||||
$target = $arguments[0] ?? null;
|
||||
|
||||
if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$request = Request::create('/', 'POST', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id))
|
||||
->toThrow(AuthorizationException::class);
|
||||
|
||||
$application->refresh();
|
||||
expect($application->environment_id)->toBe($this->environment->id);
|
||||
});
|
||||
|
||||
test('returns 400 when application is already in the target environment', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
|
||||
test('preserves resource-level environment variables after move', function () {
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
EnvironmentVariable::create([
|
||||
'key' => 'TEST_VAR',
|
||||
'value' => 'test-value',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$application->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$application->refresh();
|
||||
$envVar = $application->environment_variables->where('key', 'TEST_VAR')->first();
|
||||
expect($envVar)->not->toBeNull();
|
||||
expect($envVar->value)->toBe('test-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/move', function () {
|
||||
test('moves database to another environment', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'secret',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$database->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Database moved successfully.']);
|
||||
|
||||
$database->refresh();
|
||||
expect($database->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('returns 404 when database not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/move', function () {
|
||||
test('moves service to another environment', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/move", [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service moved successfully.']);
|
||||
|
||||
$service->refresh();
|
||||
expect($service->environment_id)->toBe($this->targetEnvironment->id);
|
||||
});
|
||||
|
||||
test('returns 404 when service not found', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/services/non-existent-uuid/move', [
|
||||
'environment_uuid' => $this->targetEnvironment->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByIp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->existingPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Existing SSH Key',
|
||||
'description' => 'Existing SSH Key',
|
||||
'private_key' => 'test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
});
|
||||
|
||||
it('generates and preselects a new private key without clearing server form data', function () {
|
||||
$component = Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.50')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('generatePrivateKey', 'ed25519')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.50')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true);
|
||||
|
||||
$newPrivateKeyId = $component->get('private_key_id');
|
||||
|
||||
expect($newPrivateKeyId)->not->toBe($this->existingPrivateKey->id)
|
||||
->and(PrivateKey::find($newPrivateKeyId))->not->toBeNull()
|
||||
->and($component->get('private_keys')->pluck('id'))->toContain($newPrivateKeyId);
|
||||
});
|
||||
|
||||
it('preselects a manually added private key without clearing server form data', function () {
|
||||
$manualPrivateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'name' => 'Manual SSH Key',
|
||||
'description' => 'Manual SSH Key',
|
||||
'private_key' => 'manual-test-private-key',
|
||||
'team_id' => $this->team->id,
|
||||
]));
|
||||
|
||||
Livewire::test(ByIp::class, [
|
||||
'private_keys' => collect([$this->existingPrivateKey]),
|
||||
'limit_reached' => false,
|
||||
])
|
||||
->set('name', 'Production Server')
|
||||
->set('description', 'Deploy target')
|
||||
->set('ip', '192.0.2.51')
|
||||
->set('user', 'deploy.user')
|
||||
->set('port', 2222)
|
||||
->set('is_build_server', true)
|
||||
->call('handlePrivateKeyCreated', $manualPrivateKey->id)
|
||||
->assertSet('private_key_id', $manualPrivateKey->id)
|
||||
->assertSet('name', 'Production Server')
|
||||
->assertSet('description', 'Deploy target')
|
||||
->assertSet('ip', '192.0.2.51')
|
||||
->assertSet('user', 'deploy.user')
|
||||
->assertSet('port', 2222)
|
||||
->assertSet('is_build_server', true)
|
||||
->assertSee('Manual SSH Key');
|
||||
});
|
||||
@@ -3,14 +3,19 @@
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -58,3 +63,146 @@ it('cleans up a closed pull request preview when pull request comment cleanup fa
|
||||
|
||||
$job->handle();
|
||||
});
|
||||
|
||||
it('skips a synchronized GitHub pull request preview when the head commit message contains skip ci', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->application->settings->update([
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/example/repo/commits/after-sha' => Http::response([
|
||||
'commit' => [
|
||||
'message' => 'docs: fix typo [skip ci]',
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$job = new ProcessGithubPullRequestWebhook(
|
||||
applicationId: $this->application->id,
|
||||
githubAppId: $githubApp->id,
|
||||
action: 'synchronize',
|
||||
pullRequestId: 42,
|
||||
pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42',
|
||||
pullRequestTitle: 'Add feature',
|
||||
beforeSha: 'before-sha',
|
||||
afterSha: 'after-sha',
|
||||
commitSha: 'after-sha',
|
||||
authorAssociation: 'OWNER',
|
||||
fullName: 'example/repo',
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
expect(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse()
|
||||
->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse();
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/after-sha');
|
||||
});
|
||||
|
||||
it('deploys a synchronized GitHub pull request preview when the head commit message does not contain skip ci', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->application->settings->update([
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/example/repo/commits/after-sha' => Http::response([
|
||||
'commit' => [
|
||||
'message' => 'docs: fix typo',
|
||||
],
|
||||
]),
|
||||
'https://api.github.com/repos/example/repo/compare/before-sha...after-sha' => Http::response([
|
||||
'files' => [
|
||||
['filename' => 'README.md'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$job = new ProcessGithubPullRequestWebhook(
|
||||
applicationId: $this->application->id,
|
||||
githubAppId: $githubApp->id,
|
||||
action: 'synchronize',
|
||||
pullRequestId: 42,
|
||||
pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42',
|
||||
pullRequestTitle: 'Add feature',
|
||||
beforeSha: 'before-sha',
|
||||
afterSha: 'after-sha',
|
||||
commitSha: 'after-sha',
|
||||
authorAssociation: 'OWNER',
|
||||
fullName: 'example/repo',
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
expect(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeTrue()
|
||||
->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('pull_request_id', 42)->where('commit', 'after-sha')->exists())->toBeTrue();
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/after-sha');
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/compare/before-sha...after-sha');
|
||||
});
|
||||
|
||||
it('skips a GitHub pull request preview when the opened or reopened head commit message contains skip ci', function (string $action) {
|
||||
Queue::fake();
|
||||
|
||||
$this->application->settings->update([
|
||||
'is_preview_deployments_enabled' => true,
|
||||
]);
|
||||
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Public GitHub',
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => true,
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.github.com/repos/example/repo/commits/head-sha' => Http::response([
|
||||
'commit' => [
|
||||
'message' => 'docs: fix typo [skip ci]',
|
||||
],
|
||||
]),
|
||||
'https://api.github.com/repos/example/repo/pulls/42/files' => Http::response([]),
|
||||
]);
|
||||
|
||||
$job = new ProcessGithubPullRequestWebhook(
|
||||
applicationId: $this->application->id,
|
||||
githubAppId: $githubApp->id,
|
||||
action: $action,
|
||||
pullRequestId: 42,
|
||||
pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42',
|
||||
pullRequestTitle: 'Add feature',
|
||||
beforeSha: null,
|
||||
afterSha: null,
|
||||
commitSha: 'head-sha',
|
||||
authorAssociation: 'OWNER',
|
||||
fullName: 'example/repo',
|
||||
);
|
||||
|
||||
$job->handle();
|
||||
|
||||
expect(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse()
|
||||
->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('pull_request_id', 42)->exists())->toBeFalse();
|
||||
|
||||
Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/commits/head-sha');
|
||||
Http::assertNotSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/example/repo/pulls/42/files');
|
||||
})->with(['opened', 'reopened']);
|
||||
@@ -34,7 +34,7 @@ afterEach(function () {
|
||||
|
||||
test('releases_url config defaults to the GitHub raw source', function () {
|
||||
expect(config('constants.coolify.releases_url'))
|
||||
->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
|
||||
->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
|
||||
});
|
||||
|
||||
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::query()->forceCreate([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
User::factory()->create(['id' => 0, 'email' => 'root@example.com']);
|
||||
});
|
||||
|
||||
it('rate limits repeated registration attempts from the same ip', function () {
|
||||
foreach (range(1, 3) as $attempt) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$attempt}",
|
||||
'email' => "attacker{$attempt}@example.com",
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'blocked@example.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
|
||||
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',
|
||||
];
|
||||
|
||||
foreach ($emails as $index => $email) {
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.'.($index + 10)])
|
||||
->post('/register', [
|
||||
'name' => "Attack User {$index}",
|
||||
'email' => $email,
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
auth()->logout();
|
||||
$this->flushSession();
|
||||
}
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.99'])
|
||||
->post('/register', [
|
||||
'name' => 'Blocked User',
|
||||
'email' => 'k.evin.mcfadden+four@btinternet.com',
|
||||
'password' => 'Password1!@',
|
||||
'password_confirmation' => 'Password1!@',
|
||||
])
|
||||
->assertTooManyRequests();
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudInitScript\Show;
|
||||
use App\Livewire\Security\CloudInitScriptForm;
|
||||
use App\Livewire\Security\CloudInitScripts;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
]));
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('cloud-init scripts page lists every supported cloud provider integration', function () {
|
||||
Livewire::test(CloudInitScripts::class)
|
||||
->assertSee('Hetzner')
|
||||
->assertSee('Vultr')
|
||||
->assertSee('DigitalOcean')
|
||||
->assertDontSee('Currently working only with Hetzner');
|
||||
});
|
||||
|
||||
test('cloud-init script form does not show a cancel button in the modal', function () {
|
||||
Livewire::test(CloudInitScriptForm::class)
|
||||
->assertSee('Create Script')
|
||||
->assertDontSee('Cancel');
|
||||
});
|
||||
|
||||
test('cloud-init script cards link to the script detail page without inline actions or created time', function () {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Docker Host Setup',
|
||||
'script' => "#cloud-config\npackages:\n - htop\n",
|
||||
]);
|
||||
|
||||
Livewire::test(CloudInitScripts::class)
|
||||
->assertSee('Docker Host Setup')
|
||||
->assertSee(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]), false)
|
||||
->assertDontSee('Created')
|
||||
->assertDontSee('Edit')
|
||||
->assertDontSee('Delete');
|
||||
});
|
||||
|
||||
test('cloud-init script detail page shows editable script fields', function () {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Docker Host Setup',
|
||||
'script' => "#cloud-config\npackages:\n - htop\n",
|
||||
]);
|
||||
|
||||
$this->get(route('security.cloud-init-scripts.show', ['cloud_init_script_uuid' => $script->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Cloud-Init Script')
|
||||
->assertSee('Docker Host Setup')
|
||||
->assertSee('Save')
|
||||
->assertSee('Delete');
|
||||
});
|
||||
|
||||
test('cloud-init script detail page updates a script', function () {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Docker Host Setup',
|
||||
'script' => "#cloud-config\npackages:\n - htop\n",
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid])
|
||||
->set('name', 'Updated Host Setup')
|
||||
->set('script', "#cloud-config\npackages:\n - curl\n")
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
$script->refresh();
|
||||
|
||||
expect($script->name)->toBe('Updated Host Setup')
|
||||
->and($script->script)->toContain('curl');
|
||||
});
|
||||
|
||||
test('cloud-init script detail page deletes a script', function () {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Docker Host Setup',
|
||||
'script' => "#cloud-config\npackages:\n - htop\n",
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['cloud_init_script_uuid' => $script->uuid])
|
||||
->call('delete')
|
||||
->assertRedirectToRoute('security.cloud-init-scripts');
|
||||
|
||||
$this->assertModelMissing($script);
|
||||
});
|
||||
|
||||
test('cloud-init scripts page backfills missing script uuids before rendering links', function () {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Legacy Script',
|
||||
'script' => "#cloud-config\npackages:\n - htop\n",
|
||||
]);
|
||||
|
||||
CloudInitScript::query()->whereKey($script->id)->update(['uuid' => null]);
|
||||
|
||||
Livewire::test(CloudInitScripts::class)
|
||||
->assertSee('Legacy Script');
|
||||
|
||||
expect($script->refresh()->uuid)->not->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudProviderTokenForm;
|
||||
use App\Livewire\Security\CloudProviderTokens;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Log::spy();
|
||||
});
|
||||
|
||||
test('adding a digitalocean token from a modal closes the modal and refreshes digitalocean token selectors', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/account' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CloudProviderTokenForm::class, [
|
||||
'modal_mode' => true,
|
||||
'provider' => 'digitalocean',
|
||||
])
|
||||
->set('name', 'Production DigitalOcean')
|
||||
->set('token', 'digitalocean-token')
|
||||
->call('addToken')
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
$dispatches = collect(data_get($component->effects, 'dispatches', []));
|
||||
|
||||
expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.digitalocean'
|
||||
&& data_get($dispatch, 'to') === 'server.new.by-digital-ocean'))->toBeTrue()
|
||||
->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded'
|
||||
&& data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('adding a cloud provider token stores an optional description', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(CloudProviderTokenForm::class)
|
||||
->set('provider', 'hetzner')
|
||||
->set('name', 'Production Hetzner')
|
||||
->set('description', 'Used for production servers in the EU region.')
|
||||
->set('token', 'hetzner-token')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('cloud_provider_tokens', [
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'name' => 'Production Hetzner',
|
||||
'description' => 'Used for production servers in the EU region.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('saved cloud provider tokens show descriptions when present', function () {
|
||||
CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Production Hetzner',
|
||||
'description' => 'Used for production servers in the EU region.',
|
||||
]);
|
||||
|
||||
Livewire::test(CloudProviderTokens::class)
|
||||
->assertSee('Production Hetzner')
|
||||
->assertSee('Used for production servers in the EU region.');
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudProviderToken\Show;
|
||||
use App\Livewire\Security\CloudProviderTokens;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('saved cloud token cards link to the token detail page', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Production Hetzner',
|
||||
]);
|
||||
|
||||
Livewire::test(CloudProviderTokens::class)
|
||||
->assertSee(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]), false);
|
||||
});
|
||||
|
||||
test('cloud token detail page shows editable name and description fields', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Production Hetzner',
|
||||
'description' => 'Used for production servers.',
|
||||
]);
|
||||
|
||||
$this->get(route('security.cloud-tokens.show', ['cloud_token_uuid' => $token->uuid]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Cloud Token')
|
||||
->assertSee('Production Hetzner')
|
||||
->assertSee('Used for production servers.')
|
||||
->assertSee('Validate')
|
||||
->assertSee('Delete');
|
||||
});
|
||||
|
||||
test('cloud token detail page updates name and description', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Production Hetzner',
|
||||
'description' => 'Used for production servers.',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
|
||||
->set('name', 'Renamed Hetzner')
|
||||
->set('description', 'Used for staging servers.')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->assertDatabaseHas('cloud_provider_tokens', [
|
||||
'id' => $token->id,
|
||||
'name' => 'Renamed Hetzner',
|
||||
'description' => 'Used for staging servers.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('cloud token detail page validates the token', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/servers?per_page=1' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
|
||||
->call('validateToken')
|
||||
->assertDispatched('success');
|
||||
});
|
||||
|
||||
test('cloud token detail page deletes unused tokens', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Production Hetzner',
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['cloud_token_uuid' => $token->uuid])
|
||||
->call('delete')
|
||||
->assertRedirectToRoute('security.cloud-tokens');
|
||||
|
||||
$this->assertModelMissing($token);
|
||||
});
|
||||
@@ -996,6 +996,43 @@ describe('API route middleware for deploy actions', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('service application lifecycle command escaping', function () {
|
||||
test('service application deploy action escapes docker command arguments', function () {
|
||||
$source = file_get_contents(app_path('Actions/Service/DeployServiceApplication.php'));
|
||||
|
||||
expect($source)->toContain('escapeshellarg($workdir)')
|
||||
->and($source)->toContain('escapeshellarg($composeFile)')
|
||||
->and($source)->toContain('escapeshellarg($service->uuid)')
|
||||
->and($source)->toContain('escapeshellarg($composeServiceName)')
|
||||
->and($source)->toContain('escapeshellarg($service->destination->network)');
|
||||
});
|
||||
|
||||
test('service application restart and stop actions escape container names', function (string $path) {
|
||||
$source = file_get_contents(app_path($path));
|
||||
|
||||
expect($source)->toContain('escapeshellarg($serviceApplication->name');
|
||||
})->with([
|
||||
'restart action' => 'Actions/Service/RestartServiceApplication.php',
|
||||
'stop action' => 'Actions/Service/StopServiceApplication.php',
|
||||
]);
|
||||
|
||||
test('docker status helper escapes container names', function () {
|
||||
$source = file_get_contents(base_path('bootstrap/helpers/docker.php'));
|
||||
|
||||
expect($source)->toContain('function buildContainerStatusCommand')
|
||||
->and($source)->toContain('escapeshellarg("name={$container_id}")')
|
||||
->and($source)->toContain('escapeshellarg($container_id)');
|
||||
});
|
||||
|
||||
test('service application logs endpoint passes raw container name to docker helpers', function () {
|
||||
$source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php'));
|
||||
|
||||
expect($source)->toContain('getContainerStatus($server, $containerName)')
|
||||
->and($source)->toContain('getContainerLogs($server, $containerName, $lines)')
|
||||
->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation', function () {
|
||||
test('rejects semicolon injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
@@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
|
||||
session(['currentTeam' => $test->team]);
|
||||
}
|
||||
|
||||
function fakeGithubManifestConversion(): void
|
||||
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_bits' => 2048,
|
||||
@@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
|
||||
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/app-manifests/*/conversions' => Http::response([
|
||||
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
|
||||
'id' => 987654,
|
||||
'slug' => 'attacker-controlled-app',
|
||||
'client_id' => 'new-client-id',
|
||||
@@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
|
||||
])->save();
|
||||
}
|
||||
|
||||
function fakeGithubInstallationVerification(int $appId): void
|
||||
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
|
||||
"{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [
|
||||
'Date' => now()->toRfc7231String(),
|
||||
]),
|
||||
'https://api.github.com/app/installations/*' => Http::response([
|
||||
"{$apiUrl}/app/installations/*" => Http::response([
|
||||
'id' => 555,
|
||||
'app_id' => $appId,
|
||||
], 200),
|
||||
@@ -183,6 +183,21 @@ it('configures an unbound github app with a valid one-time manifest state', func
|
||||
->and($this->githubApp->private_key_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('converts ghe dot com app manifests through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
fakeGithubManifestConversion('https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions');
|
||||
});
|
||||
|
||||
it('rejects replayed github app manifest states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
fakeGithubManifestConversion();
|
||||
@@ -333,6 +348,23 @@ it('sets installation id when github confirms it belongs to the app', function (
|
||||
expect($this->githubApp->installation_id)->toBe(123456);
|
||||
});
|
||||
|
||||
it('verifies ghe dot com installations through the data residency api host', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
$this->githubApp->forceFill([
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
])->save();
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com');
|
||||
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
|
||||
|
||||
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
|
||||
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456');
|
||||
});
|
||||
|
||||
it('rejects replayed github app install states', function () {
|
||||
authenticateGithubSetupCallbackTest($this);
|
||||
configureGithubAppCredentials($this->githubApp);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\PrivateKey\Create;
|
||||
use App\Livewire\Security\PrivateKey\Index;
|
||||
use App\Livewire\Security\PrivateKey\Show;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Log::spy();
|
||||
Config::set('cache.default', 'array');
|
||||
Storage::fake('ssh-keys');
|
||||
});
|
||||
|
||||
test('private key index shows highlighted add dropdown actions', function () {
|
||||
Livewire::test(Index::class)
|
||||
->assertSee('+ Add')
|
||||
->assertSee('Generate ED25519')
|
||||
->assertSee('Generate RSA')
|
||||
->assertSee('Add manually')
|
||||
->assertDontSee('Manage your SSH keys for your servers and integrations.');
|
||||
});
|
||||
|
||||
test('generating a private key from the index stores it and redirects to details', function () {
|
||||
$component = Livewire::test(Index::class)
|
||||
->call('generatePrivateKey', 'ed25519');
|
||||
|
||||
$privateKey = PrivateKey::query()->firstOrFail();
|
||||
|
||||
expect($privateKey->team_id)->toBe($this->team->id)
|
||||
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
|
||||
|
||||
$component->assertRedirect(route('security.private-key.show', [
|
||||
'private_key_uuid' => $privateKey->uuid,
|
||||
]));
|
||||
});
|
||||
|
||||
test('manual private key form does not expose key generation controls', function () {
|
||||
Livewire::test(Create::class)
|
||||
->assertDontSee('Generate new ED25519 SSH Key')
|
||||
->assertDontSee('Generate new RSA SSH Key');
|
||||
});
|
||||
|
||||
test('private key details view reminds users to install the public key', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'));
|
||||
|
||||
expect($view)->toContain("ACTION REQUIRED: Copy the 'Public Key' to your server's ~/.ssh/authorized_keys file");
|
||||
});
|
||||
|
||||
test('github app private key shows a highlighted badge under the title', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'is_git_related' => true,
|
||||
]);
|
||||
|
||||
$this->get(route('security.private-key.show', [
|
||||
'private_key_uuid' => $privateKey->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('Used by GitHub App')
|
||||
->assertDontSee('Is used by a Git App?');
|
||||
});
|
||||
|
||||
test('github app badge appears before the save button in the title row', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'));
|
||||
|
||||
$badgePosition = strpos($view, 'Used by GitHub App');
|
||||
$saveButtonPosition = strpos($view, '<x-forms.button canGate="update" :canResource="$private_key" type="submit">');
|
||||
|
||||
expect($view)->toContain('<div class="flex items-center gap-2 pb-4">')
|
||||
->and($view)->toContain('Used by GitHub App')
|
||||
->and($view)->toContain('<x-forms.button canGate="update" :canResource="$private_key" type="submit">')
|
||||
->and($badgePosition)->toBeLessThan($saveButtonPosition);
|
||||
});
|
||||
|
||||
test('used private key details disable delete with an explanation', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
$this->get(route('security.private-key.show', [
|
||||
'private_key_uuid' => $privateKey->uuid,
|
||||
]))
|
||||
->assertSuccessful()
|
||||
->assertSee('This private key is currently used by a server, application, or Git app and cannot be deleted.', false)
|
||||
->assertSee('disabled', false);
|
||||
});
|
||||
|
||||
test('used private key delete action keeps the key and shows an error', function () {
|
||||
$privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['private_key_uuid' => $privateKey->uuid])
|
||||
->call('delete')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect($privateKey->fresh())->not->toBeNull();
|
||||
});
|
||||
@@ -82,18 +82,9 @@ it('skips the job when the second push is identical', function () use ($running)
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
});
|
||||
|
||||
it('only audits sentinel pushes that dispatch a state update', function () use ($running) {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('sentinel.metrics_pushed', Mockery::on(function (array $context) {
|
||||
return $context['server_uuid'] === $this->server->uuid
|
||||
&& $context['team_id'] === $this->team->id;
|
||||
}));
|
||||
it('does not audit successful sentinel pushes', function () use ($running) {
|
||||
Log::shouldReceive('channel')->with('audit')->never();
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
||||
|
||||
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
// Note: Full Livewire integration tests require database setup
|
||||
@@ -159,7 +162,7 @@ describe('Boarding Flow Integration', function () {
|
||||
|
||||
test('completes boarding when server is created from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component with from_onboarding flag
|
||||
$component = Livewire::test(ByHetzner::class)
|
||||
@@ -176,13 +179,141 @@ describe('Boarding Flow Integration', function () {
|
||||
|
||||
test('boarding flag remains unchanged when not from onboarding', function () {
|
||||
// Verify boarding is initially enabled
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
|
||||
// Mount the component without from_onboarding flag (default false)
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('from_onboarding', false);
|
||||
|
||||
// Boarding should still be enabled since it wasn't created from onboarding
|
||||
expect($this->team->fresh()->show_boarding)->toBeTrue();
|
||||
expect((bool) $this->team->fresh()->show_boarding)->toBeTrue();
|
||||
});
|
||||
|
||||
test('uses the shared dropdown UI for advanced Hetzner options', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Hetzner options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSeeHtml('dark:bg-coolgray-100')
|
||||
->assertSeeHtml('dark:bg-transparent')
|
||||
->assertSeeHtml('@click.outside="if (! true) close()"');
|
||||
});
|
||||
|
||||
test('renders advanced Hetzner option controls inside the dropdown menu', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Extra SSH Keys')
|
||||
->assertSee('Firewalls')
|
||||
->assertSee('Private Networks')
|
||||
->assertSee('Enable Hetzner Backups')
|
||||
->assertSee('Add cloud-init script')
|
||||
->assertSee('additional 20% of the server monthly fee');
|
||||
});
|
||||
|
||||
test('shows the cloud init script name only when saving the script', function () {
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('show_cloud_init_script', true)
|
||||
->assertSee('Cloud-Init Script')
|
||||
->assertSee('Save this script for later use')
|
||||
->assertDontSee('Script name...')
|
||||
->set('save_cloud_init_script', true)
|
||||
->assertSee('Script name...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hetzner data loading', function () {
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->hetznerToken = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
'token' => 'test-hetzner-api-token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('loads firewalls and networks for the selected Hetzner token', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'locations' => [
|
||||
['id' => 1, 'name' => 'nbg1', 'city' => 'Nuremberg', 'country' => 'DE', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'server_types' => [
|
||||
['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['name' => 'nbg1']], 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'images' => [
|
||||
['id' => 15512617, 'name' => 'ubuntu-24.04', 'type' => 'system', 'deprecated' => false, 'architecture' => 'x86'],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'ssh_keys' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/firewalls*' => Http::response([
|
||||
'firewalls' => [
|
||||
['id' => 38, 'name' => 'web-firewall', 'rules' => []],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
'https://api.hetzner.cloud/v1/networks*' => Http::response([
|
||||
'networks' => [
|
||||
[
|
||||
'id' => 456,
|
||||
'name' => 'private-eu',
|
||||
'ip_range' => '10.0.0.0/16',
|
||||
'subnets' => [
|
||||
['type' => 'cloud', 'network_zone' => 'eu-central'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$component = Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->call('loadHetznerData');
|
||||
|
||||
expect($component->get('hetznerFirewalls'))->toHaveCount(1)
|
||||
->and($component->get('hetznerNetworks'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('rejects submitting without a public IP protocol before calling Hetzner', function () {
|
||||
Http::fake();
|
||||
|
||||
Livewire::test(ByHetzner::class)
|
||||
->set('current_step', 2)
|
||||
->set('selected_token_id', $this->hetznerToken->id)
|
||||
->set('server_name', 'test-server')
|
||||
->set('selected_location', 'nbg1')
|
||||
->set('selected_server_type', 'cx11')
|
||||
->set('selected_image', 15512617)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->set('enable_ipv4', false)
|
||||
->set('enable_ipv6', false)
|
||||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv4', 'enable_ipv6']);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\CreatePage;
|
||||
use App\Livewire\Server\New\ByDigitalOcean;
|
||||
use App\Livewire\Server\New\ByHetzner;
|
||||
use App\Livewire\Server\New\ByVultr;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('provider token selection redirects to a token specific server creation url', function (string $component, string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->call('selectToken', $token->id)
|
||||
->assertRedirect(route('server.create.token', [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
]));
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('provider token selection is rendered as clickable token boxes', function (string $component, string $provider, string $type, string $tokenName) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => $tokenName,
|
||||
]);
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSee($tokenName)
|
||||
->assertSee(route('server.create.token', ['type' => $type, 'token_uuid' => $token->uuid]))
|
||||
->assertDontSee('loadingProviderTokenId', false)
|
||||
->assertDontSee('coolbox-loading', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertDontSee('wire:click="selectToken(', false)
|
||||
->assertDontSee('loading-spinner', false)
|
||||
->assertDontSee('Loading Hetzner details...')
|
||||
->assertDontSee('Loading Vultr details...')
|
||||
->assertDontSee('Loading DigitalOcean details...')
|
||||
->assertDontSee('Select a saved token')
|
||||
->assertDontSee('You need a saved')
|
||||
->assertDontSee('Continue');
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Production Hetzner'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Production Vultr'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Production DigitalOcean'],
|
||||
]);
|
||||
|
||||
test('provider token selection shows an add token box when no tokens exist', function (string $component, string $title, string $description) {
|
||||
Livewire::test($component)
|
||||
->assertSee($title)
|
||||
->assertSee($description)
|
||||
->assertSee('max-w-2xl', false)
|
||||
->assertSee('coolbox', false)
|
||||
->assertSee('M12 4.5v15m7.5-7.5h-15', false)
|
||||
->assertDontSee('wire:click="selectToken(', false);
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'Add a new token', 'Add a Hetzner API token to create servers from your account.'],
|
||||
'vultr' => [ByVultr::class, 'Add a new token', 'Add a Vultr API token to create servers from your account.'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'Add a new token', 'Add a DigitalOcean API token to create Droplets from your account.'],
|
||||
]);
|
||||
|
||||
test('provider token urls pass the token uuid into the server creation page', function () {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'hetzner',
|
||||
]);
|
||||
|
||||
expect(route('server.create.token', [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
]))->toEndWith('/servers/new/hetzner/'.$token->uuid);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => 'hetzner',
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSet('type', 'hetzner')
|
||||
->assertSet('token_uuid', $token->uuid)
|
||||
->assertSet('title', 'Hetzner');
|
||||
});
|
||||
|
||||
test('provider token pages defer provider api data loading until wire init', function (string $component, string $provider, string $type, string $loadingText, string $wireInitCall) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
Livewire::test($component, ['selectedTokenUuid' => $token->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->assertSet('loading_data', true)
|
||||
->assertSee($loadingText)
|
||||
->assertSee($wireInitCall, false)
|
||||
->assertSee('text-coollabs dark:text-warning animate-spin', false)
|
||||
->assertDontSee('border-b-2 border-primary', false);
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'Loading Hetzner data...', 'wire:init="loadHetznerData"'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'Loading Vultr data...', 'wire:init="loadVultrData"'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'Loading DigitalOcean data...', 'wire:init="loadDigitalOceanData"'],
|
||||
]);
|
||||
|
||||
test('provider token pages render api error details when a selected token is invalid', function (string $component, string $provider, string $type, string $loadMethod, string $providerName, string $apiMessage) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => $providerName.' Token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response($apiMessage, 401),
|
||||
]);
|
||||
|
||||
Livewire::test($component, ['selectedTokenUuid' => $token->uuid])
|
||||
->call($loadMethod)
|
||||
->assertSet('loading_data', false)
|
||||
->assertSet('provider_data_error', "{$providerName} API error: {$apiMessage}")
|
||||
->assertDispatched('error')
|
||||
->assertSee("Unable to load {$providerName} details")
|
||||
->assertSee($apiMessage)
|
||||
->assertSee('href="'.route('server.create.type', ['type' => $type]).'"', false)
|
||||
->assertSee('wire:navigate', false)
|
||||
->assertDontSee('wire:click="previousStep"', false);
|
||||
})->with([
|
||||
'hetzner' => [ByHetzner::class, 'hetzner', 'hetzner', 'loadHetznerData', 'Hetzner', 'the token you have provided is invalid'],
|
||||
'vultr' => [ByVultr::class, 'vultr', 'vultr', 'loadVultrData', 'Vultr', 'Invalid API key'],
|
||||
'digital-ocean' => [ByDigitalOcean::class, 'digitalocean', 'digital-ocean', 'loadDigitalOceanData', 'DigitalOcean', 'Unable to authenticate you'],
|
||||
]);
|
||||
|
||||
test('back button on token specific provider creation pages returns to provider token selection', function (string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertSee(route('server.create.type', ['type' => $type]), false)
|
||||
->assertDontSee('href="'.route('server.create').'"', false);
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
|
||||
test('new token header button is hidden on token specific provider creation pages', function (string $provider, string $type) {
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
])
|
||||
->assertSee('+ New Token');
|
||||
|
||||
Livewire::test(CreatePage::class, [
|
||||
'type' => $type,
|
||||
'token_uuid' => $token->uuid,
|
||||
])
|
||||
->assertDontSee('+ New Token');
|
||||
})->with([
|
||||
'hetzner' => ['hetzner', 'hetzner'],
|
||||
'vultr' => ['vultr', 'vultr'],
|
||||
'digital-ocean' => ['digitalocean', 'digital-ocean'],
|
||||
]);
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\Navbar;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
@@ -52,3 +53,31 @@ it('shows sentinel sync status after the server is validated', function () {
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('In sync');
|
||||
});
|
||||
|
||||
it('listens for sentinel restarted broadcasts', function () {
|
||||
[$server, , $team] = makeNavbarServer(isFunctional: true);
|
||||
|
||||
Livewire::test('server.navbar', ['server' => $server])
|
||||
->assertSet('server.uuid', $server->uuid);
|
||||
|
||||
expect(app(Navbar::class)->getListeners())
|
||||
->toHaveKey("echo-private:team.{$team->id},SentinelRestarted", 'refreshSentinelStatus');
|
||||
});
|
||||
|
||||
it('refreshes sentinel status when sentinel restarts for the server', function () {
|
||||
[$server] = makeNavbarServer(isFunctional: true);
|
||||
$server->forceFill(['sentinel_updated_at' => now()->subDay()])->save();
|
||||
|
||||
expect($server->fresh()->isSentinelLive())->toBeFalse();
|
||||
|
||||
$component = Livewire::test('server.navbar', ['server' => $server->fresh()])
|
||||
->assertSee('Sentinel')
|
||||
->assertSee('Out of sync');
|
||||
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
$component
|
||||
->call('refreshSentinelStatus', ['serverUuid' => $server->uuid])
|
||||
->assertSee('In sync')
|
||||
->assertDontSee('Out of sync');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\PrivateKey\Show;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
Once::flush();
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Config::set('cache.default', 'array');
|
||||
Storage::fake('ssh-keys');
|
||||
|
||||
$this->currentPrivateKey = PrivateKey::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->currentPrivateKey->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('server private key page shows highlighted add dropdown actions', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('+ Add')
|
||||
->assertSee('Generate ED25519')
|
||||
->assertSee('Generate RSA')
|
||||
->assertSee('Add manually')
|
||||
->assertSee('Check connection');
|
||||
});
|
||||
|
||||
test('generating a server private key stores it and refreshes the current view', function () {
|
||||
$component = Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('generatePrivateKey', 'ed25519')
|
||||
->assertNoRedirect()
|
||||
->assertDispatched('success');
|
||||
|
||||
$privateKey = PrivateKey::query()
|
||||
->where('id', '!=', $this->currentPrivateKey->id)
|
||||
->firstOrFail();
|
||||
|
||||
expect($privateKey->team_id)->toBe($this->team->id)
|
||||
->and($privateKey->public_key)->toStartWith('ssh-ed25519');
|
||||
|
||||
$component
|
||||
->assertDispatched('copyPublicKeyToClipboard', publicKey: $privateKey->public_key)
|
||||
->assertSee($privateKey->name);
|
||||
});
|
||||
|
||||
test('server private key page copies generated public keys and shows a copied hint', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
|
||||
|
||||
expect($view)->toContain('copyPublicKeyToClipboard')
|
||||
->and($view)->toContain('navigator.clipboard.writeText')
|
||||
->and($view)->toContain('Public key copied to clipboard.');
|
||||
});
|
||||
|
||||
test('server private key cards include a copy public key button', function () {
|
||||
$keyData = PrivateKey::generateNewKeyPair('rsa');
|
||||
|
||||
PrivateKey::createAndStore([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Alternative SSH Key',
|
||||
'description' => 'Created by test',
|
||||
'private_key' => $keyData['private_key'],
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Copy public key')
|
||||
->assertSee('Alternative SSH Key');
|
||||
|
||||
$view = file_get_contents(resource_path('views/livewire/server/private-key/show.blade.php'));
|
||||
|
||||
expect($view)->toContain('Copy public key')
|
||||
->and($view)->toContain('$private_key->public_key')
|
||||
->and($view)->toContain('Public key copied to clipboard.');
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Service\DeployServiceApplication;
|
||||
use App\Actions\Service\RestartServiceApplication;
|
||||
use App\Actions\Service\StopServiceApplication;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createServiceWithApplicationForApiTest(object $ctx): object
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
|
||||
$sa = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'web',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return (object) ['service' => $service, 'serviceApplication' => $sa];
|
||||
}
|
||||
|
||||
function createServiceWithoutApplicationsForApiTest(object $ctx): Service
|
||||
{
|
||||
return Service::factory()->create([
|
||||
'environment_id' => $ctx->environment->id,
|
||||
'server_id' => $ctx->server->id,
|
||||
'destination_id' => $ctx->destination->id,
|
||||
'destination_type' => $ctx->destination->getMorphClass(),
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications', function () {
|
||||
test('returns empty array when service has no applications', function () {
|
||||
$service = createServiceWithoutApplicationsForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([]);
|
||||
});
|
||||
|
||||
test('lists service applications for the service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]);
|
||||
});
|
||||
|
||||
test('returns 404 when service does not exist', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('does not list applications from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherServer->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
|
||||
$otherCtx = (object) [
|
||||
'server' => $otherServer,
|
||||
'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(),
|
||||
'environment' => Environment::factory()->create([
|
||||
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
|
||||
]),
|
||||
];
|
||||
$ctx = createServiceWithApplicationForApiTest($otherCtx);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 404 for unknown service', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345');
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid is not under service', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003");
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJsonFragment(['message' => 'Service application not found.']);
|
||||
});
|
||||
|
||||
test('returns service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [
|
||||
'human_name' => 'x',
|
||||
], ['Accept' => 'application/json', 'Content-Type' => 'application/json']);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('updates human_name', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Web UI',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['human_name' => 'Web UI']);
|
||||
$ctx->serviceApplication->refresh();
|
||||
expect($ctx->serviceApplication->human_name)->toBe('Web UI');
|
||||
});
|
||||
|
||||
test('returns 422 for invalid url scheme', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'url' => 'ftp://example.com',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('returns 422 when enabling log drain but server has no log drain', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'is_log_drain_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain');
|
||||
});
|
||||
|
||||
test('read-only token cannot update a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readOnlyToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [
|
||||
'human_name' => 'Blocked',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('returns 409 for domain conflicts unless forced', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']);
|
||||
$otherApplication = ServiceApplication::create([
|
||||
'name' => 'admin',
|
||||
'service_id' => $ctx->service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(409);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [
|
||||
'url' => 'http://used.example.com',
|
||||
])->assertStatus(200);
|
||||
|
||||
$otherApplication->refresh();
|
||||
expect($otherApplication->fqdn)->toBe('http://used.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () {
|
||||
test('returns 400 without valid token', function () {
|
||||
$response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('queues restart for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application restart request queued.']);
|
||||
RestartServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () {
|
||||
test('write token cannot start a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start");
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('queues deploy for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application deploy request queued.']);
|
||||
DeployServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () {
|
||||
test('queues stop for a service application', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment(['message' => 'Service application stop request queued.']);
|
||||
StopServiceApplication::assertPushed();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () {
|
||||
test('returns 400 when server is not functional', function () {
|
||||
$ctx = createServiceWithApplicationForApiTest($this);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => false,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs");
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJsonFragment(['message' => 'Server is not functional.']);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,19 @@ it('caches parsed local service templates by bundle mtime', function () {
|
||||
expect($first->keys()->all())->toBe($second->keys()->all());
|
||||
});
|
||||
|
||||
it('renders the shared loading indicator while resource choices load', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee('Loading resources...', false);
|
||||
$view->assertSee('text-coollabs dark:text-warning animate-spin', false);
|
||||
$view->assertDontSee('<div x-show="loading">Loading...</div>', false);
|
||||
});
|
||||
|
||||
it('renders the service templates last updated hint placeholder', function () {
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
|
||||
@@ -46,8 +46,15 @@ beforeEach(function () {
|
||||
|
||||
function createTestApplication($context): Application
|
||||
{
|
||||
return Application::factory()->create([
|
||||
return Application::create([
|
||||
'name' => 'test-storage-app',
|
||||
'git_repository' => 'https://github.com/test/test',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,6 +72,19 @@ function createTestDatabase($context): StandalonePostgresql
|
||||
]);
|
||||
}
|
||||
|
||||
function createStorageApiToken($context, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $context->user->tokens()->create([
|
||||
'name' => 'storage-test-token',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $context->team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createTestServiceApplication($context): array
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
@@ -114,6 +134,39 @@ describe('GET /api/v1/applications/{uuid}/storages', function () {
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('hides file storage content for read tokens and reveals it for sensitive tokens', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => '/tmp/config.json',
|
||||
'mount_path' => '/app/config.json',
|
||||
'content' => '{"secret": "hidden"}',
|
||||
'is_directory' => false,
|
||||
'resource_id' => $app->id,
|
||||
'resource_type' => $app->getMorphClass(),
|
||||
]);
|
||||
|
||||
$readToken = createStorageApiToken($this, ['read']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['read', 'read:sensitive']);
|
||||
|
||||
$readResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
])->getJson("/api/v1/applications/{$app->uuid}/storages");
|
||||
|
||||
auth()->forgetGuards();
|
||||
|
||||
$sensitiveResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
])->getJson("/api/v1/applications/{$app->uuid}/storages");
|
||||
|
||||
$readResponse->assertSuccessful();
|
||||
$sensitiveResponse->assertSuccessful();
|
||||
|
||||
expect($readResponse->getContent())->not->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
@@ -214,6 +267,39 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('hides created file storage content for write tokens and reveals it for sensitive tokens', function () {
|
||||
$app = createTestApplication($this);
|
||||
$writeToken = createStorageApiToken($this, ['write']);
|
||||
$sensitiveToken = createStorageApiToken($this, ['write', 'read:sensitive']);
|
||||
|
||||
$writeResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$writeToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/app/hidden.json',
|
||||
'content' => '{"secret": "write-hidden"}',
|
||||
]);
|
||||
|
||||
auth()->forgetGuards();
|
||||
|
||||
$sensitiveResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$sensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/app/visible.json',
|
||||
'content' => '{"secret": "write-visible"}',
|
||||
]);
|
||||
|
||||
$writeResponse->assertCreated();
|
||||
$sensitiveResponse->assertCreated();
|
||||
|
||||
expect($writeResponse->getContent())->not->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('"content":')
|
||||
->and($sensitiveResponse->getContent())->toContain('write-visible');
|
||||
});
|
||||
|
||||
test('rejects persistent storage without name', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
|
||||
+189
-43
@@ -1,63 +1,107 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\SyncBunny;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function createSyncBunnyFailingBinary(string $binDir, string $name): void
|
||||
function createFakeSyncBunnyBinary(string $binDir, string $name, string $contents): void
|
||||
{
|
||||
file_put_contents("{$binDir}/{$name}", <<<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s %s\n' "$(basename "$0")" "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
exit 1
|
||||
SH);
|
||||
file_put_contents("{$binDir}/{$name}", $contents);
|
||||
chmod("{$binDir}/{$name}", 0755);
|
||||
}
|
||||
|
||||
it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
|
||||
Http::fake([
|
||||
'storage.bunnycdn.com/*' => Http::response([], 201),
|
||||
'api.bunny.net/purge*' => Http::response([], 200),
|
||||
]);
|
||||
it('only exposes the BunnyCDN legacy sync option', function () {
|
||||
$definition = Artisan::all()['sync:bunny']->getDefinition();
|
||||
|
||||
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
|
||||
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
|
||||
|
||||
mkdir($binDir, 0755, true);
|
||||
createSyncBunnyFailingBinary($binDir, 'gh');
|
||||
createSyncBunnyFailingBinary($binDir, 'git');
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$binDir}:{$originalPath}");
|
||||
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
|
||||
|
||||
try {
|
||||
$this->artisan('sync:bunny --release --nightly')
|
||||
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
|
||||
->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
|
||||
->doesntExpectOutputToContain('GitHub PR')
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
putenv('SYNC_BUNNY_TEST_LOG');
|
||||
}
|
||||
|
||||
expect(file_exists($logFile))->toBeFalse();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
|
||||
expect($definition->hasOption('bunny'))->toBeTrue()
|
||||
->and($definition->hasOption('github-releases'))->toBeFalse()
|
||||
->and($definition->hasOption('release'))->toBeFalse()
|
||||
->and($definition->hasOption('nightly'))->toBeFalse()
|
||||
->and($definition->hasOption('templates'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
|
||||
it('loads service templates from the Coollabs CDN', function () {
|
||||
expect(config('constants.services.official'))
|
||||
->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
|
||||
});
|
||||
|
||||
it('only removes validated Coolify CDN temporary directories', function () {
|
||||
$command = new class extends SyncBunny
|
||||
{
|
||||
public function removeDirectory(string $path): void
|
||||
{
|
||||
$this->removeTemporaryDirectory($path);
|
||||
}
|
||||
};
|
||||
|
||||
$invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid();
|
||||
$validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid();
|
||||
mkdir($invalidDirectory);
|
||||
mkdir($validDirectory);
|
||||
|
||||
$command->removeDirectory('');
|
||||
$command->removeDirectory($invalidDirectory);
|
||||
$command->removeDirectory($validDirectory);
|
||||
|
||||
expect($invalidDirectory)->toBeDirectory()
|
||||
->and($validDirectory)->not->toBeDirectory();
|
||||
|
||||
rmdir($invalidDirectory);
|
||||
});
|
||||
|
||||
it('syncs full files to BunnyCDN only when explicitly requested', function () {
|
||||
Http::fake([
|
||||
'https://cdn.coollabs.io/coolify/*' => Http::response('', 404),
|
||||
'https://storage.bunnycdn.com/*' => Http::response([], 201),
|
||||
'https://api.bunny.net/purge*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$this->artisan('sync:bunny')
|
||||
->expectsConfirmation('Are you sure you want to sync?', 'yes')
|
||||
->expectsOutputToContain('BunnyCDN sync: Complete')
|
||||
->assertExitCode(0);
|
||||
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
|
||||
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
|
||||
|
||||
mkdir($binDir, 0755, true);
|
||||
|
||||
createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
|
||||
#!/bin/sh
|
||||
printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
|
||||
mkdir -p "$4/scripts"
|
||||
fi
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
|
||||
#!/bin/sh
|
||||
printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
if [ "$1" = "status" ]; then
|
||||
printf 'M scripts/upgrade-postgres.sh\n'
|
||||
fi
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$binDir}:{$originalPath}");
|
||||
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
|
||||
|
||||
try {
|
||||
$this->artisan('sync:bunny --bunny')
|
||||
->expectsChoice('Which environment would you like to sync?', 'production', [
|
||||
'production' => 'Production',
|
||||
'nightly' => 'Nightly',
|
||||
])
|
||||
->expectsConfirmation('Are you sure you want to sync?', 'yes')
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
putenv('SYNC_BUNNY_TEST_LOG');
|
||||
}
|
||||
|
||||
$log = file_exists($logFile) ? file_get_contents($logFile) : '';
|
||||
|
||||
expect($log)
|
||||
->not->toContain('gh repo clone')
|
||||
->not->toContain('gh pr create')
|
||||
->not->toContain('coollabsio/coolify-cdn');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'PUT'
|
||||
&& $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh');
|
||||
@@ -65,3 +109,105 @@ it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
|
||||
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
|
||||
&& $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh');
|
||||
});
|
||||
|
||||
it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
|
||||
Http::fake([
|
||||
'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
$binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
|
||||
$logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
|
||||
|
||||
mkdir($binDir, 0755, true);
|
||||
|
||||
createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
|
||||
#!/bin/sh
|
||||
printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
|
||||
mkdir -p "$4"
|
||||
fi
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
|
||||
#!/bin/sh
|
||||
printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
|
||||
if [ "$1" = "status" ]; then
|
||||
printf 'M json/releases.json\n'
|
||||
fi
|
||||
if [ "$1" = "diff" ]; then
|
||||
if [ -f json/coolify/nightly/releases.json ]; then
|
||||
printf 'json/coolify/nightly/releases.json\n'
|
||||
else
|
||||
printf 'json/coolify/releases.json\n'
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
SH);
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$binDir}:{$originalPath}");
|
||||
putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
|
||||
|
||||
$allBasenames = [
|
||||
'releases.json',
|
||||
'versions.json',
|
||||
'docker-compose.yml',
|
||||
'docker-compose.prod.yml',
|
||||
'.env.production',
|
||||
'install.sh',
|
||||
'upgrade.sh',
|
||||
'upgrade-postgres.sh',
|
||||
'service-templates-latest.json',
|
||||
];
|
||||
$allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames);
|
||||
$selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames);
|
||||
|
||||
try {
|
||||
$this->artisan('sync:bunny')
|
||||
->expectsChoice('Which environment would you like to sync?', $environment, [
|
||||
'production' => 'Production',
|
||||
'nightly' => 'Nightly',
|
||||
])
|
||||
->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets)
|
||||
->assertExitCode(0);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
putenv('SYNC_BUNNY_TEST_LOG');
|
||||
}
|
||||
|
||||
$log = file_get_contents($logFile);
|
||||
|
||||
expect($log)
|
||||
->toContain('gh pr create --repo coollabsio/coollabs-cdn')
|
||||
->not->toContain('coollabsio/coolify-cdn');
|
||||
|
||||
foreach ($selectedTargets as $selectedTarget) {
|
||||
expect($log)->toContain($selectedTarget);
|
||||
}
|
||||
|
||||
foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) {
|
||||
expect($log)->not->toContain($unselectedTarget);
|
||||
}
|
||||
|
||||
$pullRequestCommand = substr($log, strrpos($log, 'gh pr create'));
|
||||
|
||||
expect($pullRequestCommand)
|
||||
->toContain("$targetDirectory/releases.json")
|
||||
->not->toContain("$targetDirectory/versions.json");
|
||||
|
||||
Http::assertSentCount(1);
|
||||
})->with([
|
||||
'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']],
|
||||
'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [
|
||||
'releases.json',
|
||||
'versions.json',
|
||||
'docker-compose.yml',
|
||||
'docker-compose.prod.yml',
|
||||
'.env.production',
|
||||
'install.sh',
|
||||
'upgrade.sh',
|
||||
'upgrade-postgres.sh',
|
||||
'service-templates-latest.json',
|
||||
]],
|
||||
]);
|
||||
@@ -0,0 +1,547 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\Tags as TagsComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function tagApiAuthHeaders($bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/tags', function () {
|
||||
test('returns all tags for current team', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
Tag::create(['name' => 'staging', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
$response->assertJsonFragment(['name' => 'staging']);
|
||||
});
|
||||
|
||||
test('returns empty array when no tags exist', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
|
||||
test('does not return tags from other teams', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
|
||||
Tag::create(['name' => 'my-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/tags');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'my-tag']);
|
||||
$response->assertJsonMissing(['name' => 'other-team-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/applications/{uuid}/tags', function () {
|
||||
test('returns tags for an application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/applications/non-existent-uuid/tags');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns empty array when application has no tags', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/tags', function () {
|
||||
test('adds a single tag via tag_name', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('adds multiple tags via tag_names array', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_names' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonCount(2);
|
||||
|
||||
expect($this->application->tags()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('reuses existing team tag instead of creating duplicate', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('rejects tag_name shorter than 2 characters', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'x',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('rejects both tag_name and tag_names provided simultaneously', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
'tag_names' => ['staging'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonFragment(['tag_name' => ['Provide either tag_name or tag_names, not both.']]);
|
||||
});
|
||||
|
||||
test('skips duplicate tag already on resource', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($this->application->tags()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/non-existent-uuid/tags', [
|
||||
'tag_name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('garbage-collects orphaned tag', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 when deleting a tag that is not attached to the application', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('keeps tag if still used by other resources', function () {
|
||||
$tag = Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
$this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent tag', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/non-existent-uuid");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/databases/{uuid}/tags', function () {
|
||||
test('returns tags for a database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/databases/{$database->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'database-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/tags', function () {
|
||||
test('adds tag to database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/databases/{$database->uuid}/tags", [
|
||||
'tag_name' => 'database-tag',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/databases/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from database', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'database-tag', 'team_id' => $this->team->id]);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/databases/{$database->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($database->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/services/{uuid}/tags', function () {
|
||||
test('returns tags for a service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/services/{$service->uuid}/tags");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(1);
|
||||
$response->assertJsonFragment(['name' => 'service-tag']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/tags', function () {
|
||||
test('adds tag to service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/services/{$service->uuid}/tags", [
|
||||
'tag_name' => 'service-tag',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect($service->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/services/{uuid}/tags/{tag_uuid}', function () {
|
||||
test('removes tag from service', function () {
|
||||
$service = Service::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'service-tag', 'team_id' => $this->team->id]);
|
||||
$service->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/services/{$service->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($service->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource creation tags', function () {
|
||||
test('adds tags while creating a public application', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'static',
|
||||
'ports_exposes' => '80',
|
||||
'autogenerate_domain' => false,
|
||||
'tags' => ['production', 'frontend'],
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$application = Application::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($application->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['frontend', 'production']);
|
||||
});
|
||||
|
||||
test('adds tags while creating a postgresql database', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/databases/postgresql', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['database', 'production'],
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$database = StandalonePostgresql::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($database->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['database', 'production']);
|
||||
});
|
||||
|
||||
test('adds tags while creating a docker compose service', function () {
|
||||
$compose = base64_encode(<<<'YAML'
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
YAML);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/services', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'docker_compose_raw' => $compose,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['service', 'production'],
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$service = Service::whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($service->tags()->pluck('name')->sort()->values()->all())
|
||||
->toBe(['production', 'service']);
|
||||
});
|
||||
|
||||
test('rejects creation tags that are too short after sanitization', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/databases/postgresql', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'instant_deploy' => false,
|
||||
'tags' => ['<b>x</b>'],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag name sanitization', function () {
|
||||
test('strips HTML tags from tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => '<script>alert("xss")</script>production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'alert("xss")production']);
|
||||
$response->assertJsonMissing(['name' => '<script>']);
|
||||
});
|
||||
|
||||
test('lowercases tag names', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonFragment(['name' => 'production']);
|
||||
});
|
||||
|
||||
test('rejects tag names that are too short after sanitization', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/tags", [
|
||||
'tag_name' => '<b>x</b>',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
expect($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag uniqueness', function () {
|
||||
test('prevents duplicate tag names per team at the database level', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
expect(fn () => Tag::create(['name' => 'production', 'team_id' => $this->team->id]))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tag cleanup across resource types', function () {
|
||||
test('does not delete a tag still attached to a database when removed from an application in the UI', function () {
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'test-pg',
|
||||
'postgres_password' => 'testpassword',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
$database->tags()->attach($tag->id);
|
||||
|
||||
Livewire::test(TagsComponent::class, ['resource' => $this->application])
|
||||
->call('deleteTag', (string) $tag->id);
|
||||
|
||||
expect(Tag::find($tag->id))->not->toBeNull()
|
||||
->and($database->tags()->count())->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-resource tag isolation', function () {
|
||||
test('cannot delete tag via wrong resource', function () {
|
||||
$tag = Tag::create(['name' => 'shared-tag', 'team_id' => $this->team->id]);
|
||||
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$otherApp->tags()->attach($tag->id);
|
||||
|
||||
// Tag is not attached to $this->application, but we try to delete via it
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
// Tag should still exist on the other app (detach on non-attached is a no-op)
|
||||
expect($otherApp->tags()->count())->toBe(1);
|
||||
// Tag should NOT be garbage-collected since otherApp still uses it
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
$this->vultrToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-api-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Private Key',
|
||||
'description' => 'Test private key',
|
||||
'private_key' => testPrivateKey(),
|
||||
]);
|
||||
});
|
||||
|
||||
function testPrivateKey(): string
|
||||
{
|
||||
return <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
}
|
||||
|
||||
function testPublicKey(): string
|
||||
{
|
||||
return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68';
|
||||
}
|
||||
|
||||
describe('GET /api/v1/vultr/regions', function () {
|
||||
test('gets Vultr regions', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/regions*' => Http::response([
|
||||
'regions' => [
|
||||
['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'],
|
||||
['id' => 'ams', 'city' => 'Amsterdam', 'country' => 'NL'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/vultr/regions?cloud_provider_token_id='.$this->vultrToken->uuid);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['id' => 'ewr']);
|
||||
});
|
||||
|
||||
test('requires cloud_provider_token_id parameter', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/vultr/regions');
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['cloud_provider_token_id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/vultr/plans', function () {
|
||||
test('gets Vultr plans', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/plans*' => Http::response([
|
||||
'plans' => [
|
||||
['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25],
|
||||
['id' => 'vc2-2c-2gb', 'vcpu_count' => 2, 'ram' => 2048, 'disk' => 55],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/vultr/plans?cloud_provider_token_uuid='.$this->vultrToken->uuid);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['id' => 'vc2-1c-1gb']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/vultr/os', function () {
|
||||
test('gets Vultr operating systems', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/os*' => Http::response([
|
||||
'os' => [
|
||||
['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'],
|
||||
['id' => 2136, 'name' => 'Debian 12 x64'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/vultr/os?cloud_provider_token_id='.$this->vultrToken->uuid);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'Ubuntu 24.04 LTS x64']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/vultr/ssh-keys', function () {
|
||||
test('gets Vultr SSH keys', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [
|
||||
['id' => 'key-1', 'name' => 'my-key', 'ssh_key' => testPublicKey()],
|
||||
['id' => 'key-2', 'name' => 'another-key', 'ssh_key' => 'ssh-ed25519 AAAAother'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/vultr/ssh-keys?cloud_provider_token_id='.$this->vultrToken->uuid);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(2);
|
||||
$response->assertJsonFragment(['name' => 'my-key']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/vultr', function () {
|
||||
test('creates a Vultr server', 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-1',
|
||||
'label' => 'test-server',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$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' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv6' => true,
|
||||
'cloud_init_script' => "#cloud-config\npackages:\n - curl",
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'vultr_instance_id', 'ip']);
|
||||
$response->assertJsonFragment(['vultr_instance_id' => 'instance-1', 'ip' => '1.2.3.4']);
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'ip' => '1.2.3.4',
|
||||
'team_id' => $this->team->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.vultr.com/v2/instances'
|
||||
&& $request['region'] === 'ewr'
|
||||
&& $request['plan'] === 'vc2-1c-1gb'
|
||||
&& $request['os_id'] === 2284
|
||||
&& $request['sshkey_id'] === ['key-1']
|
||||
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
|
||||
});
|
||||
});
|
||||
|
||||
test('waits for Vultr to assign a real public IP when creation returns placeholder IP', 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-1',
|
||||
'main_ip' => '0.0.0.0',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'status' => 'active',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$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' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['ip' => '1.2.3.4']);
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-server',
|
||||
'ip' => '1.2.3.4',
|
||||
'vultr_instance_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reuses existing matching Vultr SSH key', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||
'ssh_keys' => [
|
||||
['id' => 'existing-key', 'name' => 'Existing', 'ssh_key' => testPublicKey().' comment'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$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' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
Http::assertNotSent(function ($request) {
|
||||
return $request->url() === 'https://api.vultr.com/v2/ssh-keys'
|
||||
&& $request->method() === 'POST';
|
||||
});
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.vultr.com/v2/instances'
|
||||
&& $request['sshkey_id'] === ['existing-key'];
|
||||
});
|
||||
});
|
||||
|
||||
test('validates required fields', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/vultr', []);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent Vultr token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/servers/vultr', [
|
||||
'cloud_provider_token_id' => 'non-existent-uuid',
|
||||
'region' => 'ewr',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
'os_id' => 2284,
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJson(['message' => 'Vultr cloud provider token not found.']);
|
||||
});
|
||||
|
||||
test('uses IPv6 when public IPv4 is disabled', 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-1',
|
||||
'main_ip' => '',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$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' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv6' => true,
|
||||
'disable_public_ipv4' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonFragment(['ip' => '2001:db8::1']);
|
||||
});
|
||||
|
||||
test('requires IPv6 when public IPv4 is disabled', function () {
|
||||
$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' => 'test-server',
|
||||
'private_key_uuid' => $this->privateKey->uuid,
|
||||
'enable_ipv6' => false,
|
||||
'disable_public_ipv4' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['enable_ipv6']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Server\New\ByVultr;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->vultrToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Private Key',
|
||||
'description' => 'Test private key',
|
||||
'private_key' => vultrLivewireTestPrivateKey(),
|
||||
]);
|
||||
});
|
||||
|
||||
function vultrLivewireTestPrivateKey(): string
|
||||
{
|
||||
return <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
}
|
||||
|
||||
function vultrLivewireTestPublicKey(): string
|
||||
{
|
||||
return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68';
|
||||
}
|
||||
|
||||
it('creates a Vultr server through the Livewire flow', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/regions*' => Http::response([
|
||||
'regions' => [
|
||||
['id' => 'ewr', 'city' => 'New Jersey', 'country' => 'US'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/plans*' => Http::response([
|
||||
'plans' => [
|
||||
['id' => 'vc2-1c-1gb', 'vcpu_count' => 1, 'ram' => 1024, 'disk' => 25, 'monthly_cost' => 6, 'locations' => ['ewr']],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/os*' => Http::response([
|
||||
'os' => [
|
||||
['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'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' => 'test-vultr-server',
|
||||
'main_ip' => '1.2.3.4',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||
->assertSet('current_step', 2)
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
->set('selected_plan', 'vc2-1c-1gb')
|
||||
->set('selected_os_id', 2284)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->set('enable_ipv6', true)
|
||||
->set('cloud_init_script', "#cloud-config\npackages:\n - curl")
|
||||
->call('submit');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'name' => 'test-vultr-server',
|
||||
'ip' => '1.2.3.4',
|
||||
'team_id' => $this->team->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.vultr.com/v2/instances'
|
||||
&& $request['region'] === 'ewr'
|
||||
&& $request['plan'] === 'vc2-1c-1gb'
|
||||
&& $request['os_id'] === 2284
|
||||
&& $request['sshkey_id'] === ['key-1']
|
||||
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
|
||||
});
|
||||
});
|
||||
|
||||
it('requires IPv6 when public IPv4 is disabled', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('selected_token_id', $this->vultrToken->id)
|
||||
->set('current_step', 2)
|
||||
->set('server_name', 'test-vultr-server')
|
||||
->set('selected_region', 'ewr')
|
||||
->set('selected_plan', 'vc2-1c-1gb')
|
||||
->set('selected_os_id', 2284)
|
||||
->set('private_key_id', $this->privateKey->id)
|
||||
->set('enable_ipv6', false)
|
||||
->set('disable_public_ipv4', true)
|
||||
->call('submit')
|
||||
->assertHasErrors(['enable_ipv6']);
|
||||
});
|
||||
|
||||
it('uses the shared dropdown UI for advanced Vultr options', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertSee('Advanced Vultr options')
|
||||
->assertSeeHtml('dropdownOpen')
|
||||
->assertSeeHtml('x-ref="panel"')
|
||||
->assertSee('Additional SSH Keys (from Vultr)')
|
||||
->assertSee('Network Configuration')
|
||||
->assertSee('Cloud-Init Script');
|
||||
});
|
||||
|
||||
it('renders only the full width buy button at the bottom of the Vultr form', function () {
|
||||
Livewire::test(ByVultr::class)
|
||||
->set('current_step', 2)
|
||||
->assertDontSee('wire:click="previousStep"', false)
|
||||
->assertSeeHtml('class="button w-full"')
|
||||
->assertSee('Buy & Create Server', false);
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\ValidateServer as ValidateServerAction;
|
||||
use App\Livewire\Server\Show;
|
||||
use App\Livewire\Server\ValidateAndInstall;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.maintenance.driver' => 'file',
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Private Key',
|
||||
'description' => 'Test private key',
|
||||
'private_key' => vultrLifecycleTestPrivateKey(),
|
||||
]);
|
||||
|
||||
$this->vultrToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'vultr_instance_status' => 'pending',
|
||||
'ip' => '1.2.3.4',
|
||||
]);
|
||||
});
|
||||
|
||||
function vultrLifecycleTestPrivateKey(): string
|
||||
{
|
||||
return <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
}
|
||||
|
||||
it('refreshes Vultr instance status', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('checkVultrInstanceStatus', true)
|
||||
->assertSet('vultrInstanceStatus', 'active');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes Vultr status on server page load even when cached status is active', function () {
|
||||
$this->server->update(['vultr_instance_status' => 'active']);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('$wire.checkVultrInstanceStatus();', false);
|
||||
});
|
||||
|
||||
it('updates placeholder server IP when Vultr status refresh returns assigned public IP', function () {
|
||||
$this->server->update(['ip' => '0.0.0.0']);
|
||||
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
'main_ip' => '9.8.7.6',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('checkVultrInstanceStatus', true)
|
||||
->assertSet('ip', '9.8.7.6');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'ip' => '9.8.7.6',
|
||||
'vultr_instance_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not overwrite an established server IP during Vultr status refresh', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
'main_ip' => '9.8.7.6',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('checkVultrInstanceStatus', true)
|
||||
->assertSet('ip', '1.2.3.4')
|
||||
->assertSet('vultrInstanceStatus', 'active');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'ip' => '1.2.3.4',
|
||||
'vultr_instance_status' => 'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks server page validation when Vultr instance is stopped', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
'power_status' => 'stopped',
|
||||
'main_ip' => '1.2.3.4',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('validateServer')
|
||||
->assertSet('vultrInstanceStatus', 'stopped')
|
||||
->assertDispatched('error', 'Vultr instance is stopped. Power it on before validating.')
|
||||
->assertNotDispatched('init');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'stopped',
|
||||
]);
|
||||
|
||||
expect($this->server->fresh()->validation_logs)->toBeNull();
|
||||
});
|
||||
|
||||
it('marks missing Vultr instances as deleted before validation', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'error' => 'instance not found',
|
||||
], 404),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('validateServer')
|
||||
->assertSet('vultrInstanceStatus', 'deleted')
|
||||
->assertDispatched('error', 'Vultr instance is deleted or no longer accessible. Relink this server before validating.')
|
||||
->assertNotDispatched('init');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'deleted',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks validation modal connection checks when Vultr instance is stopped', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
'power_status' => 'stopped',
|
||||
'main_ip' => '1.2.3.4',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(ValidateAndInstall::class, ['server' => $this->server])
|
||||
->call('validateConnection')
|
||||
->assertSet('error', 'Vultr instance is stopped. Power it on before validating.')
|
||||
->assertNotDispatched('validateOS');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'stopped',
|
||||
'validation_logs' => 'Vultr instance is stopped. Power it on before validating.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks action validation when Vultr instance is stopped', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'status' => 'active',
|
||||
'power_status' => 'stopped',
|
||||
'main_ip' => '1.2.3.4',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
expect(fn () => ValidateServerAction::run($this->server))
|
||||
->toThrow(Exception::class, 'Vultr instance is stopped. Power it on before validating.');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'stopped',
|
||||
'validation_logs' => 'Vultr instance is stopped. Power it on before validating.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows Vultr power on button when stopped even if server was previously functional', function () {
|
||||
$this->server->update([
|
||||
'ip' => '1.2.3.5',
|
||||
'vultr_instance_status' => 'stopped',
|
||||
]);
|
||||
$this->server->settings->update([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]);
|
||||
|
||||
expect($this->server->fresh()->isFunctional())->toBeTrue();
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Power On');
|
||||
});
|
||||
|
||||
it('starts a Vultr instance', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response([], 204),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('startVultrInstance')
|
||||
->assertSet('vultrInstanceStatus', 'starting');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $this->server->id,
|
||||
'vultr_instance_status' => 'starting',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance-1/start');
|
||||
});
|
||||
|
||||
it('links a server to Vultr by matching IP', function () {
|
||||
$unlinkedServer = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'ip' => '5.6.7.8',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances?per_page=100' => Http::response([
|
||||
'instances' => [
|
||||
[
|
||||
'id' => 'instance-2',
|
||||
'label' => 'matched-server',
|
||||
'main_ip' => '5.6.7.8',
|
||||
'status' => 'active',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/instances/instance-2' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-2',
|
||||
'status' => 'active',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $unlinkedServer->uuid])
|
||||
->set('selectedVultrTokenId', $this->vultrToken->id)
|
||||
->call('searchVultrInstance')
|
||||
->assertSet('matchedVultrInstance.id', 'instance-2')
|
||||
->call('linkToVultr');
|
||||
|
||||
$this->assertDatabaseHas('servers', [
|
||||
'id' => $unlinkedServer->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-2',
|
||||
'vultr_instance_status' => 'active',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
it('normalizes requested log line counts', function () {
|
||||
if (! function_exists('normalizeLogLines')) {
|
||||
expect(function_exists('normalizeLogLines'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
expect(normalizeLogLines(null))->toBe(100)
|
||||
->and(normalizeLogLines(''))->toBe(100)
|
||||
->and(normalizeLogLines('abc'))->toBe(100)
|
||||
->and(normalizeLogLines('0'))->toBe(100)
|
||||
->and(normalizeLogLines('-5'))->toBe(100)
|
||||
->and(normalizeLogLines('50'))->toBe(50)
|
||||
->and(normalizeLogLines('50000'))->toBe(10000);
|
||||
});
|
||||
|
||||
it('parses show_timestamps query values as booleans', function () {
|
||||
if (! function_exists('parseLogTimestampFlag')) {
|
||||
expect(function_exists('parseLogTimestampFlag'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
expect(parseLogTimestampFlag('true'))->toBeTrue()
|
||||
->and(parseLogTimestampFlag('1'))->toBeTrue()
|
||||
->and(parseLogTimestampFlag(true))->toBeTrue()
|
||||
->and(parseLogTimestampFlag('false'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag('0'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag(false))->toBeFalse()
|
||||
->and(parseLogTimestampFlag('not-a-bool'))->toBeFalse()
|
||||
->and(parseLogTimestampFlag(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('builds docker log commands with options before an escaped container id', function () {
|
||||
if (! function_exists('buildContainerLogsCommand')) {
|
||||
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = new Server;
|
||||
$server->settings = ['is_swarm_manager' => false];
|
||||
|
||||
expect(buildContainerLogsCommand($server, 'container-1', 25, true))
|
||||
->toBe("docker logs -n 25 --timestamps 'container-1' 2>&1");
|
||||
});
|
||||
|
||||
it('builds swarm service log commands with options before an escaped service id', function () {
|
||||
if (! function_exists('buildContainerLogsCommand')) {
|
||||
expect(function_exists('buildContainerLogsCommand'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$server = new Server;
|
||||
$server->settings = ['is_swarm_manager' => true];
|
||||
|
||||
expect(buildContainerLogsCommand($server, "service'name", 25, true))
|
||||
->toBe("docker service logs -n 25 --timestamps 'service'\\''name' 2>&1");
|
||||
});
|
||||
|
||||
it('filters service sub containers in PHP instead of using user input in shell filters', function () {
|
||||
if (! function_exists('filterServiceSubContainersByName')) {
|
||||
expect(function_exists('filterServiceSubContainersByName'))->toBeTrue();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containers = collect([
|
||||
['ID' => 'first', 'Labels' => 'coolify.serviceId=10,coolify.name=app-service-uuid,coolify.type=service'],
|
||||
['ID' => 'second', 'Labels' => 'coolify.serviceId=10,coolify.name=db-service-uuid,coolify.type=service'],
|
||||
['ID' => 'third', 'Labels' => ['coolify.name' => 'app-service-uuid']],
|
||||
]);
|
||||
|
||||
expect(filterServiceSubContainersByName($containers, 'app-service-uuid')->pluck('ID')->all())
|
||||
->toBe(['first', 'third']);
|
||||
});
|
||||
|
||||
it('does not interpolate the requested service name into the docker ps shell command', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../../bootstrap/helpers/docker.php');
|
||||
|
||||
expect($source)->not->toContain('coolify.name={$name}');
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
it('allows selecting Vultr in cloud provider token forms', function () {
|
||||
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$component = file_get_contents(__DIR__.'/../../app/Livewire/Security/CloudProviderTokenForm.php');
|
||||
|
||||
expect($view)->toContain('<x-forms.select required id="provider" label="Provider" wire:model.live="provider">')
|
||||
->and($view)->toContain('<option value="vultr">Vultr</option>')
|
||||
->and($view)->toContain('<option value="digitalocean">DigitalOcean</option>')
|
||||
->and(substr_count($view, 'Open Account → API Access.'))->toBe(2)
|
||||
->and(substr_count($view, 'https://console.vultr.com/user/apiaccess/'))->toBe(2)
|
||||
->and($view)->not->toContain('cloudProviderTokens->where(\'provider\', $provider)->isEmpty()')
|
||||
->and($view)->not->toContain('<x-forms.select required id="provider" label="Provider" disabled>')
|
||||
->and($component)->toContain("'provider' => 'required|string|in:hetzner,digitalocean,vultr'");
|
||||
});
|
||||
|
||||
it('keeps provider affiliate links on server provider views', function () {
|
||||
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$hetznerView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-hetzner.blade.php');
|
||||
$vultrView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-vultr.blade.php');
|
||||
$digitalOceanView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/new/by-digital-ocean.blade.php');
|
||||
|
||||
expect($tokenFormView)
|
||||
->not->toContain('https://coolify.io/hetzner')
|
||||
->not->toContain('https://coolify.io/vultr')
|
||||
->and($hetznerView)->toContain('https://coolify.io/hetzner')
|
||||
->and($vultrView)->toContain('https://coolify.io/vultr')
|
||||
->and($digitalOceanView)->toContain('https://coolify.io/digitalocean');
|
||||
});
|
||||
|
||||
it('uses a provider dropdown and modal forms on cloud provider tokens page', function () {
|
||||
$view = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-tokens.blade.php');
|
||||
|
||||
expect($view)->toContain('+ Add')
|
||||
->and($view)->toContain('<div class="flex items-center gap-2">')
|
||||
->and($view)->toContain('<div class="grid gap-4 lg:grid-cols-2">')
|
||||
->and($view)->toContain('class="coolbox group"')
|
||||
->and($view)->toContain('<div class="box-title">')
|
||||
->and($view)->toContain('<div class="box-description">')
|
||||
->and($view)->toContain('<x-forms.button isHighlighted @click="dropdownOpen = !dropdownOpen" type="button">')
|
||||
->and($view)->toContain('Add Hetzner Token')
|
||||
->and($view)->toContain('Add DigitalOcean Token')
|
||||
->and($view)->toContain('Add Vultr Token')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="hetzner"')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean"')
|
||||
->and($view)->toContain('<livewire:security.cloud-provider-token-form :modal_mode="true" provider="vultr"')
|
||||
->and($view)->not->toContain('<h3>New Token</h3>')
|
||||
->and($view)->not->toContain(':modal_mode="false"')
|
||||
->and($view)->not->toContain('wire:click="validateToken')
|
||||
->and($view)->not->toContain('submitAction="deleteToken')
|
||||
->and($view)->not->toContain('Created {{ $savedToken->created_at->diffForHumans() }}');
|
||||
});
|
||||
|
||||
it('shows explicit loading spinners and disables cloud token action buttons while requests run', function () {
|
||||
$tokenFormView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token-form.blade.php');
|
||||
$tokenShowView = file_get_contents(__DIR__.'/../../resources/views/livewire/security/cloud-provider-token/show.blade.php');
|
||||
$serverTokenView = file_get_contents(__DIR__.'/../../resources/views/livewire/server/cloud-provider-token/show.blade.php');
|
||||
|
||||
expect(substr_count($tokenFormView, 'wire:loading.attr="disabled" wire:target="addToken"'))->toBe(2)
|
||||
->and(substr_count($tokenFormView, '<x-loading-on-button wire:loading wire:target="addToken" />'))->toBe(2)
|
||||
->and($tokenShowView)->toContain('wire:loading.attr="disabled" wire:target="validateToken"')
|
||||
->and($tokenShowView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />')
|
||||
->and($serverTokenView)->toContain('wire:loading.attr="disabled"')
|
||||
->and($serverTokenView)->toContain('wire:target="validateToken"')
|
||||
->and($serverTokenView)->toContain('<x-loading-on-button wire:loading wire:target="validateToken" />');
|
||||
});
|
||||
@@ -66,12 +66,16 @@ it('detects redeploy-only domain changes', function () {
|
||||
$application = snapshotTestApplication();
|
||||
markSnapshotTestApplicationDeployed($application);
|
||||
|
||||
$application->update(['fqdn' => 'https://new.example.com']);
|
||||
$domains = 'https://new.example.com,https://another.example.com';
|
||||
$application->update(['fqdn' => $domains]);
|
||||
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
|
||||
$change = collect($diff->changes())->firstWhere('label', 'Domains');
|
||||
|
||||
expect($diff->isChanged())->toBeTrue()
|
||||
->and($diff->requiresBuild())->toBeFalse()
|
||||
->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
|
||||
->and($change)->not->toBeNull()
|
||||
->and($change['expandable'])->toBeTrue()
|
||||
->and($change['new_full_value'])->toBe($domains);
|
||||
});
|
||||
|
||||
it('detects environment variable value changes without exposing secret values', function () {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('deletes the DigitalOcean droplet when requested', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
'personal_team' => false,
|
||||
]);
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
]);
|
||||
|
||||
DeleteServer::run(
|
||||
serverId: $server->id,
|
||||
deleteFromDigitalOcean: true,
|
||||
digitalOceanDropletId: 987,
|
||||
cloudProviderTokenId: $token->id,
|
||||
teamId: $team->id,
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.digitalocean.com/v2/droplets/987');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
it('renders the official DigitalOcean logo path', function () {
|
||||
$contents = file_get_contents(__DIR__.'/../../resources/views/components/digital-ocean-icon.blade.php');
|
||||
|
||||
expect($contents)
|
||||
->toContain('viewBox="0 0 24 24"')
|
||||
->toContain('M12.04 0C5.408-.02.005 5.37.005 11.992h4.638')
|
||||
->toContain('DigitalOcean');
|
||||
});
|
||||
|
||||
it('does not use the old custom DigitalOcean icon in Blade views', function () {
|
||||
$oldIconPaths = [
|
||||
'M100 42c31.5 0 57 25.5 57 57s-25.5 57-57 57H72v-36h28c11.6 0 21-9.4 21-21s-9.4-21-21-21-21 9.4-21 21H43c0-31.5 25.5-57 57-57z',
|
||||
'M43 120h36v36H43zM79 156h28v28H79zM43 156h28v28H43z',
|
||||
];
|
||||
|
||||
$bladeFiles = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator(__DIR__.'/../../resources/views')
|
||||
);
|
||||
|
||||
foreach ($bladeFiles as $bladeFile) {
|
||||
if (! $bladeFile->isFile() || $bladeFile->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contents = file_get_contents($bladeFile->getPathname());
|
||||
|
||||
foreach ($oldIconPaths as $oldIconPath) {
|
||||
expect($contents)
|
||||
->not->toContain($oldIconPath, $bladeFile->getPathname().' contains the old DigitalOcean icon.');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
||||
{
|
||||
$team = Team::create([
|
||||
'name' => 'Test Team',
|
||||
'personal_team' => false,
|
||||
]);
|
||||
$token = CloudProviderToken::create([
|
||||
'team_id' => $team->id,
|
||||
'provider' => 'digitalocean',
|
||||
'token' => 'test-digitalocean-token',
|
||||
'name' => 'DigitalOcean',
|
||||
]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||
|
||||
return Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'cloud_provider_token_id' => $token->id,
|
||||
'digitalocean_droplet_id' => 987,
|
||||
'digitalocean_droplet_status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
|
||||
$server = createDigitalOceanServerForStateTest();
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'not found'], 404),
|
||||
]);
|
||||
|
||||
expect($server->refreshDigitalOceanState())->toBe('deleted');
|
||||
|
||||
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
|
||||
});
|
||||
|
||||
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
|
||||
$server = createDigitalOceanServerForStateTest();
|
||||
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'server error'], 500),
|
||||
]);
|
||||
|
||||
expect(fn () => $server->refreshDigitalOceanState())
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect($server->fresh()->digitalocean_droplet_status)->toBe('active');
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use App\Services\DigitalOceanService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('fetches paginated regions from DigitalOcean', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => Http::response([
|
||||
'regions' => [
|
||||
['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true],
|
||||
],
|
||||
'links' => ['pages' => ['next' => 'https://api.digitalocean.com/v2/regions?page=2&per_page=50']],
|
||||
]),
|
||||
'https://api.digitalocean.com/v2/regions?page=2&per_page=50' => Http::response([
|
||||
'regions' => [
|
||||
['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true],
|
||||
],
|
||||
'links' => ['pages' => []],
|
||||
]),
|
||||
]);
|
||||
|
||||
$regions = (new DigitalOceanService('test-token'))->getRegions();
|
||||
|
||||
expect($regions)->toHaveCount(2)
|
||||
->and($regions[0]['slug'])->toBe('nyc1')
|
||||
->and($regions[1]['slug'])->toBe('ams3');
|
||||
});
|
||||
|
||||
it('creates a droplet with the selected SSH keys and options', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'name' => 'coolify-test',
|
||||
'status' => 'new',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$droplet = (new DigitalOceanService('test-token'))->createDroplet([
|
||||
'name' => 'coolify-test',
|
||||
'region' => 'nyc1',
|
||||
'size' => 's-1vcpu-1gb',
|
||||
'image' => 'ubuntu-24-04-x64',
|
||||
'ssh_keys' => [123],
|
||||
'ipv6' => true,
|
||||
'monitoring' => true,
|
||||
'user_data' => '#cloud-config',
|
||||
]);
|
||||
|
||||
expect($droplet['id'])->toBe(987);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets'
|
||||
&& $request['ssh_keys'] === [123]
|
||||
&& $request['ipv6'] === true
|
||||
&& $request['monitoring'] === true
|
||||
&& $request['user_data'] === '#cloud-config');
|
||||
});
|
||||
|
||||
it('extracts a public IPv4 address before falling back to IPv6', function () {
|
||||
$droplet = [
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '10.0.0.2', 'type' => 'private'],
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
'v6' => [
|
||||
['ip_address' => '2001:db8::10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->getPublicIpAddress($droplet, true, true))->toBe('203.0.113.10')
|
||||
->and($service->getPublicIpAddress($droplet, false, true))->toBe('2001:db8::10');
|
||||
});
|
||||
|
||||
it('waits for DigitalOcean to assign a public IP', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => 'active',
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
$droplet = $service->waitForPublicIp([
|
||||
'id' => 987,
|
||||
'status' => 'new',
|
||||
'networks' => ['v4' => []],
|
||||
], sleepMilliseconds: 0);
|
||||
|
||||
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10');
|
||||
});
|
||||
|
||||
it('keeps polling when DigitalOcean assigns a public IP slowly', function () {
|
||||
$polls = 0;
|
||||
|
||||
Http::fake(function () use (&$polls) {
|
||||
$polls++;
|
||||
|
||||
return Http::response([
|
||||
'droplet' => [
|
||||
'id' => 987,
|
||||
'status' => $polls < 10 ? 'new' : 'active',
|
||||
'networks' => [
|
||||
'v4' => $polls < 10 ? [] : [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
$droplet = $service->waitForPublicIp([
|
||||
'id' => 987,
|
||||
'status' => 'new',
|
||||
'networks' => ['v4' => []],
|
||||
], sleepMilliseconds: 0);
|
||||
|
||||
expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10')
|
||||
->and($polls)->toBe(10);
|
||||
});
|
||||
|
||||
it('finds a droplet by public IP', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets*' => Http::response([
|
||||
'droplets' => [
|
||||
[
|
||||
'id' => 987,
|
||||
'networks' => [
|
||||
'v4' => [
|
||||
['ip_address' => '203.0.113.10', 'type' => 'public'],
|
||||
],
|
||||
'v6' => [
|
||||
['ip_address' => '2001:db8::10', 'type' => 'public'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'links' => ['pages' => []],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->findDropletByIp('203.0.113.10')['id'])->toBe(987)
|
||||
->and($service->findDropletByIp('2001:db8::10')['id'])->toBe(987)
|
||||
->and($service->findDropletByIp('198.51.100.1'))->toBeNull();
|
||||
});
|
||||
|
||||
it('powers on and deletes a DigitalOcean droplet', function () {
|
||||
Http::fake([
|
||||
'https://api.digitalocean.com/v2/droplets/987/actions' => Http::response([
|
||||
'action' => ['id' => 111, 'type' => 'power_on'],
|
||||
], 201),
|
||||
'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$service = new DigitalOceanService('test-token');
|
||||
|
||||
expect($service->powerOnDroplet(987)['type'])->toBe('power_on');
|
||||
$service->deleteDroplet(987);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<?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');
|
||||
});
|
||||
|
||||
it('returns null for blank or malformed email identities', function (?string $email) {
|
||||
expect(normalize_email_identity($email))->toBeNull();
|
||||
})->with([
|
||||
null,
|
||||
'',
|
||||
'not-an-email',
|
||||
'@example.com',
|
||||
'local@',
|
||||
]);
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('classifies github hosts', function () {
|
||||
expect(isGithubDotComHost('https://github.com'))->toBeTrue()
|
||||
->and(isGheDotComHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://octocorp.ghe.com'))->toBeTrue()
|
||||
->and(isGithubCloudFamilyHost('https://github.com'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://github.company.internal'))->toBeTrue()
|
||||
->and(isGithubEnterpriseServerHost('https://octocorp.ghe.com'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('derives github api urls from html urls', function (string $htmlUrl, string $apiUrl) {
|
||||
expect(githubApiUrlFromHtmlUrl($htmlUrl))->toBe($apiUrl);
|
||||
})->with([
|
||||
'github.com' => ['https://github.com', 'https://api.github.com'],
|
||||
'ghe.com data residency' => ['https://octocorp.ghe.com', 'https://api.octocorp.ghe.com'],
|
||||
'github enterprise server' => ['https://github.company.internal', 'https://github.company.internal/api/v3'],
|
||||
]);
|
||||
|
||||
it('rejects malformed urls when deriving an origin', function (string $url) {
|
||||
expect(fn () => githubUrlOrigin($url))->toThrow(InvalidArgumentException::class);
|
||||
})->with([
|
||||
'blank' => [''],
|
||||
'scheme-less' => ['github.company.internal'],
|
||||
'malformed' => ['not-a-url'],
|
||||
]);
|
||||
|
||||
it('generates correct install paths for github cloud ghe cloud and ghes', function (array $attributes, string $expectedPrefix) {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill(array_merge([
|
||||
'id' => 123,
|
||||
'name' => 'Coolify Test App',
|
||||
'team_id' => 456,
|
||||
], $attributes));
|
||||
|
||||
$installationUrl = getInstallationPath($githubApp);
|
||||
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
|
||||
$state = $query['state'] ?? null;
|
||||
|
||||
expect($installationUrl)->toStartWith($expectedPrefix)
|
||||
->and($state)->not->toBeEmpty()
|
||||
->and(Cache::get('github-app-setup-state:'.hash('sha256', $state)))
|
||||
->toMatchArray([
|
||||
'action' => 'install',
|
||||
'github_app_id' => 123,
|
||||
'team_id' => 456,
|
||||
]);
|
||||
})->with([
|
||||
'github.com' => [
|
||||
['html_url' => 'https://github.com'],
|
||||
'https://github.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com organization' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => 'octo-corp'],
|
||||
'https://octocorp.ghe.com/apps/octo-corp/coolify-test-app/installations/new?',
|
||||
],
|
||||
'ghe.com blank organization fallback' => [
|
||||
['html_url' => 'https://octocorp.ghe.com', 'organization' => null],
|
||||
'https://octocorp.ghe.com/apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
'github enterprise server' => [
|
||||
['html_url' => 'https://github.company.internal', 'organization' => 'octo-corp'],
|
||||
'https://github.company.internal/github-apps/coolify-test-app/installations/new?',
|
||||
],
|
||||
]);
|
||||
|
||||
it('encodes organization path segments in settings links', function () {
|
||||
$githubApp = new GithubApp;
|
||||
$githubApp->forceFill([
|
||||
'name' => 'coolify-app',
|
||||
'organization' => 'octo+corp',
|
||||
'api_url' => 'https://api.octocorp.ghe.com',
|
||||
'html_url' => 'https://octocorp.ghe.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'team_id' => 123,
|
||||
]);
|
||||
|
||||
expect(getPermissionsPath($githubApp))->toBe('https://octocorp.ghe.com/organizations/octo%2Bcorp/settings/apps/coolify-app/permissions');
|
||||
});
|
||||
@@ -33,6 +33,19 @@ it('ensures GlobalSearch uses redirect route helper', function () {
|
||||
->toContain('redirectRoute($this, \'project.resource.create\'');
|
||||
});
|
||||
|
||||
it('routes new server quick action to the new server page', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($globalSearchFile)
|
||||
->toContain("if (\$type === 'server')")
|
||||
->toContain("redirectRoute(\$this, 'server.create')");
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain("window.location.href = '/servers/new'")
|
||||
->not->toContain('@open-create-modal-server.window');
|
||||
});
|
||||
|
||||
it('ensures docker-image item has quickcommand with new image', function () {
|
||||
$globalSearchFile = file_get_contents(__DIR__.'/../../app/Livewire/GlobalSearch.php');
|
||||
|
||||
@@ -142,3 +155,13 @@ it('uses cropped image assets instead of inline wide logos for GlobalSearch data
|
||||
|
||||
expect($globalSearchFile)->not->toContain("'logo_html' =>");
|
||||
});
|
||||
|
||||
it('uses rounded yellow plus icons for GlobalSearch creatable actions without logos', function () {
|
||||
$bladeFile = file_get_contents(__DIR__.'/../../resources/views/livewire/global-search.blade.php');
|
||||
|
||||
expect($bladeFile)
|
||||
->toContain('rounded-full bg-warning/20 flex items-center justify-center')
|
||||
->toContain('class="h-6 w-6 text-warning"')
|
||||
->not->toContain('rounded-lg bg-warning-100 dark:bg-warning-900/40')
|
||||
->not->toContain('text-warning-600 dark:text-warning-400');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('accepts hostnames containing underscores', function () {
|
||||
// Regression: PHP's FILTER_VALIDATE_URL rejects underscores in the host,
|
||||
// which blocked valid service domains (e.g. Docker service naming) from
|
||||
// being saved and getting Let's Encrypt certificates. See issue #10597.
|
||||
expect(isValidDomainUrl('https://myapp_service.example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('http://my_app.example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('https://a_b_c.example.com/path'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('accepts ordinary domains and URLs', function () {
|
||||
expect(isValidDomainUrl('https://example.com'))->toBeTrue();
|
||||
expect(isValidDomainUrl('http://sub.example.com:8080/path?q=1'))->toBeTrue();
|
||||
expect(isValidDomainUrl('https://example.com/a_b'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects strings that are not valid URLs', function () {
|
||||
expect(isValidDomainUrl('not a url'))->toBeFalse();
|
||||
expect(isValidDomainUrl('example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
|
||||
expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
|
||||
expect(isValidDomainUrl('data://example.com'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
|
||||
describe('Sensitive model fields are hidden by default', function () {
|
||||
test('ServerSetting hides sentinel and logdrain secrets', function () {
|
||||
$hidden = (new ServerSetting)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'sentinel_token',
|
||||
'sentinel_custom_url',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
);
|
||||
});
|
||||
|
||||
test('Server hides logdrain api keys', function () {
|
||||
$hidden = (new Server)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_newrelic_license_key',
|
||||
);
|
||||
});
|
||||
|
||||
test('Application hides webhook secrets, dockerfile, compose, and labels', function () {
|
||||
$hidden = (new Application)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'http_basic_auth_password',
|
||||
'manual_webhook_secret_github',
|
||||
'manual_webhook_secret_gitlab',
|
||||
'manual_webhook_secret_bitbucket',
|
||||
'manual_webhook_secret_gitea',
|
||||
'dockerfile',
|
||||
'docker_compose',
|
||||
'docker_compose_raw',
|
||||
'custom_labels',
|
||||
);
|
||||
});
|
||||
|
||||
test('EnvironmentVariable hides value and real_value', function () {
|
||||
$hidden = (new EnvironmentVariable)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('value', 'real_value');
|
||||
});
|
||||
|
||||
test('SharedEnvironmentVariable hides value', function () {
|
||||
$hidden = (new SharedEnvironmentVariable)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('value');
|
||||
});
|
||||
|
||||
test('Service hides docker_compose and docker_compose_raw', function () {
|
||||
$hidden = (new Service)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('docker_compose', 'docker_compose_raw');
|
||||
});
|
||||
|
||||
test('ApplicationDeploymentQueue hides logs', function () {
|
||||
$hidden = (new ApplicationDeploymentQueue)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('logs');
|
||||
});
|
||||
|
||||
test('LocalFileVolume hides file content', function () {
|
||||
$hidden = (new LocalFileVolume)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('content');
|
||||
});
|
||||
|
||||
test('PrivateKey hides private key material', function () {
|
||||
$hidden = (new PrivateKey)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('private_key');
|
||||
});
|
||||
|
||||
test('CloudProviderToken hides provider token', function () {
|
||||
$hidden = (new CloudProviderToken)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('token');
|
||||
});
|
||||
|
||||
test('CloudInitScript hides script content', function () {
|
||||
$hidden = (new CloudInitScript)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('script');
|
||||
});
|
||||
|
||||
test('S3Storage hides credentials', function () {
|
||||
$hidden = (new S3Storage)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('key', 'secret');
|
||||
});
|
||||
|
||||
test('OauthSetting hides client secret', function () {
|
||||
$hidden = (new OauthSetting)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('client_secret');
|
||||
});
|
||||
|
||||
test('SslCertificate hides private key', function () {
|
||||
$hidden = (new SslCertificate)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('ssl_private_key');
|
||||
});
|
||||
|
||||
test('EmailNotificationSettings hides SMTP and resend secrets', function () {
|
||||
$hidden = (new EmailNotificationSettings)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'smtp_from_address',
|
||||
'smtp_from_name',
|
||||
'smtp_recipients',
|
||||
'smtp_host',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'resend_api_key',
|
||||
);
|
||||
});
|
||||
|
||||
test('InstanceSettings hides instance notification secrets', function () {
|
||||
$hidden = (new InstanceSettings)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'smtp_from_address',
|
||||
'smtp_from_name',
|
||||
'smtp_recipients',
|
||||
'smtp_host',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'resend_api_key',
|
||||
'sentinel_token',
|
||||
);
|
||||
});
|
||||
|
||||
test('Webhook-style notification settings hide delivery endpoints', function () {
|
||||
expect((new DiscordNotificationSettings)->getHidden())->toContain('discord_webhook_url');
|
||||
expect((new SlackNotificationSettings)->getHidden())->toContain('slack_webhook_url');
|
||||
expect((new WebhookNotificationSettings)->getHidden())->toContain('webhook_url');
|
||||
});
|
||||
|
||||
test('PushoverNotificationSettings hides credentials', function () {
|
||||
$hidden = (new PushoverNotificationSettings)->getHidden();
|
||||
|
||||
expect($hidden)->toContain('pushover_user_key', 'pushover_api_token');
|
||||
});
|
||||
|
||||
test('TelegramNotificationSettings hides bot, chat, and thread identifiers', function () {
|
||||
$hidden = (new TelegramNotificationSettings)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'telegram_token',
|
||||
'telegram_chat_id',
|
||||
'telegram_notifications_deployment_success_thread_id',
|
||||
'telegram_notifications_deployment_failure_thread_id',
|
||||
'telegram_notifications_status_change_thread_id',
|
||||
'telegram_notifications_backup_success_thread_id',
|
||||
'telegram_notifications_backup_failure_thread_id',
|
||||
'telegram_notifications_scheduled_task_success_thread_id',
|
||||
'telegram_notifications_scheduled_task_failure_thread_id',
|
||||
'telegram_notifications_docker_cleanup_success_thread_id',
|
||||
'telegram_notifications_docker_cleanup_failure_thread_id',
|
||||
'telegram_notifications_server_disk_usage_thread_id',
|
||||
'telegram_notifications_server_reachable_thread_id',
|
||||
'telegram_notifications_server_unreachable_thread_id',
|
||||
'telegram_notifications_server_patch_thread_id',
|
||||
'telegram_notifications_traefik_outdated_thread_id',
|
||||
);
|
||||
});
|
||||
|
||||
test('TelegramNotificationSettings casts actual docker cleanup thread ids as encrypted', function () {
|
||||
$casts = (new TelegramNotificationSettings)->getCasts();
|
||||
|
||||
expect($casts)->toMatchArray([
|
||||
'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted',
|
||||
'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted',
|
||||
]);
|
||||
});
|
||||
|
||||
test('StandalonePostgresql hides password, init_scripts, db urls', function () {
|
||||
$hidden = (new StandalonePostgresql)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'postgres_password',
|
||||
'init_scripts',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneMysql hides passwords and db urls', function () {
|
||||
$hidden = (new StandaloneMysql)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'mysql_password',
|
||||
'mysql_root_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneMariadb hides passwords and db urls', function () {
|
||||
$hidden = (new StandaloneMariadb)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'mariadb_password',
|
||||
'mariadb_root_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneMongodb hides root password and db urls', function () {
|
||||
$hidden = (new StandaloneMongodb)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'mongo_initdb_root_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneRedis hides password and db urls', function () {
|
||||
$hidden = (new StandaloneRedis)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'redis_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneClickhouse hides password and db urls', function () {
|
||||
$hidden = (new StandaloneClickhouse)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'clickhouse_admin_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneKeydb hides password and db urls', function () {
|
||||
$hidden = (new StandaloneKeydb)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'keydb_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
|
||||
test('StandaloneDragonfly hides password and db urls', function () {
|
||||
$hidden = (new StandaloneDragonfly)->getHidden();
|
||||
|
||||
expect($hidden)->toContain(
|
||||
'dragonfly_password',
|
||||
'internal_db_url',
|
||||
'external_db_url',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive fields are absent from toArray() by default', function () {
|
||||
test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () {
|
||||
$setting = new ServerSetting;
|
||||
$setting->setRawAttributes([
|
||||
'server_id' => 1,
|
||||
'sentinel_custom_url' => 'https://secret.example.com',
|
||||
'wildcard_domain' => 'public.example.com',
|
||||
], sync: true);
|
||||
|
||||
$array = $setting->toArray();
|
||||
|
||||
expect($array)->not->toHaveKey('sentinel_custom_url');
|
||||
expect($array)->toHaveKey('wildcard_domain');
|
||||
});
|
||||
|
||||
test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () {
|
||||
$setting = new ServerSetting;
|
||||
$setting->setRawAttributes([
|
||||
'server_id' => 1,
|
||||
'sentinel_custom_url' => 'https://secret.example.com',
|
||||
], sync: true);
|
||||
|
||||
$setting->makeVisible(['sentinel_custom_url']);
|
||||
$array = $setting->toArray();
|
||||
|
||||
expect($array['sentinel_custom_url'])->toBe('https://secret.example.com');
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,37 @@ describe('shouldSkipDueToBackoff', function () {
|
||||
});
|
||||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('marks Vultr servers unreachable when provider status is unavailable', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = true;
|
||||
$settings->force_disabled = false;
|
||||
$settings->shouldReceive('update')
|
||||
->with(['is_reachable' => false, 'is_usable' => false])
|
||||
->once();
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->andReturnUsing(fn (string $key) => match ($key) {
|
||||
'settings' => $settings,
|
||||
'unreachable_notification_sent' => false,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
'cloudProviderToken' => (object) ['token' => 'test-token'],
|
||||
'id' => 1,
|
||||
'name' => 'test-server',
|
||||
'unreachable_count' => 1,
|
||||
default => null,
|
||||
});
|
||||
$server->shouldReceive('refreshVultrState')->once()->andReturn('stopped');
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->uuid = 'server-uuid';
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->handle();
|
||||
});
|
||||
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
test('server header renders subtitle before status badges', function () {
|
||||
$navbar = file_get_contents(__DIR__.'/../../resources/views/livewire/server/navbar.blade.php');
|
||||
|
||||
$titlePosition = strpos($navbar, '<h1>Server</h1>');
|
||||
$subtitlePosition = strpos($navbar, 'data-testid="server-subtitle"');
|
||||
$statusPosition = strpos($navbar, 'data-testid="server-status-summary"');
|
||||
|
||||
expect($navbar)->not->toContain('class="subtitle"')
|
||||
->not->toContain('text-neutral-300')
|
||||
->toContain('text-neutral-600 dark:text-neutral-400');
|
||||
|
||||
expect($titlePosition)->not->toBeFalse()
|
||||
->and($subtitlePosition)->not->toBeFalse()
|
||||
->and($statusPosition)->not->toBeFalse()
|
||||
->and($titlePosition)->toBeLessThan($subtitlePosition)
|
||||
->and($subtitlePosition)->toBeLessThan($statusPosition);
|
||||
});
|
||||
@@ -187,3 +187,7 @@ it('normalizes application domain scheme and host without lowercasing path query
|
||||
expect(ValidationPatterns::normalizeApplicationDomains($domains))
|
||||
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
|
||||
});
|
||||
|
||||
it('validates application domains with underscores in the hostname', function () {
|
||||
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\DeleteServer;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_api_enabled' => true,
|
||||
]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Private Key',
|
||||
'description' => 'Test private key',
|
||||
'private_key' => vultrDeleteTestPrivateKey(),
|
||||
]);
|
||||
|
||||
$this->vultrToken = CloudProviderToken::create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'test-vultr-token',
|
||||
'name' => 'Test Vultr Token',
|
||||
]);
|
||||
});
|
||||
|
||||
function vultrDeleteTestPrivateKey(): string
|
||||
{
|
||||
return <<<'KEY'
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
KEY;
|
||||
}
|
||||
|
||||
it('deletes a Vultr instance when requested', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
]);
|
||||
|
||||
$server->delete();
|
||||
|
||||
DeleteServer::run(
|
||||
serverId: $server->id,
|
||||
cloudProviderTokenId: $this->vultrToken->id,
|
||||
teamId: $this->team->id,
|
||||
deleteFromVultr: true,
|
||||
vultrInstanceId: 'instance-1'
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
|
||||
&& $request->url() === 'https://api.vultr.com/v2/instances/instance-1');
|
||||
|
||||
expect(Server::withTrashed()->find($server->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not use another team Vultr token when deleting an instance', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([], 204),
|
||||
]);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherToken = CloudProviderToken::create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'provider' => 'vultr',
|
||||
'token' => 'other-team-vultr-token',
|
||||
'name' => 'Other Team Vultr Token',
|
||||
]);
|
||||
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||
'vultr_instance_id' => 'instance-1',
|
||||
]);
|
||||
|
||||
$server->delete();
|
||||
|
||||
DeleteServer::run(
|
||||
serverId: $server->id,
|
||||
cloudProviderTokenId: $otherToken->id,
|
||||
teamId: $this->team->id,
|
||||
deleteFromVultr: true,
|
||||
vultrInstanceId: 'instance-1'
|
||||
);
|
||||
|
||||
$request = Http::recorded()->first()[0];
|
||||
|
||||
expect($request->header('Authorization'))->toBe(['Bearer test-vultr-token']);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Services\VultrService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('gets instances from Vultr API', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
'instances' => [
|
||||
[
|
||||
'id' => 'instance-1',
|
||||
'label' => 'test-server-1',
|
||||
'status' => 'active',
|
||||
'main_ip' => '123.45.67.89',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
],
|
||||
[
|
||||
'id' => 'instance-2',
|
||||
'label' => 'test-server-2',
|
||||
'status' => 'stopped',
|
||||
'main_ip' => '98.76.54.32',
|
||||
'v6_main_ip' => '2001:db8::2',
|
||||
],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
$instances = $service->getInstances();
|
||||
|
||||
expect($instances)->toBeArray()
|
||||
->and($instances)->toHaveCount(2)
|
||||
->and($instances[0]['id'])->toBe('instance-1')
|
||||
->and($instances[1]['id'])->toBe('instance-2');
|
||||
});
|
||||
|
||||
it('follows cursor pagination', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/regions?per_page=100' => Http::response([
|
||||
'regions' => [
|
||||
['id' => 'ewr', 'city' => 'New Jersey'],
|
||||
],
|
||||
'meta' => [
|
||||
'links' => [
|
||||
'next' => 'https://api.vultr.com/v2/regions?cursor=next-cursor',
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
'https://api.vultr.com/v2/regions?per_page=100&cursor=next-cursor' => Http::response([
|
||||
'regions' => [
|
||||
['id' => 'ams', 'city' => 'Amsterdam'],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
$regions = $service->getRegions();
|
||||
|
||||
expect($regions)->toHaveCount(2)
|
||||
->and($regions[0]['id'])->toBe('ewr')
|
||||
->and($regions[1]['id'])->toBe('ams');
|
||||
});
|
||||
|
||||
it('base64 encodes user data when creating an instance', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '123.45.67.89',
|
||||
'status' => 'pending',
|
||||
],
|
||||
], 202),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
$service->createInstance([
|
||||
'region' => 'ewr',
|
||||
'plan' => 'vc2-1c-1gb',
|
||||
'os_id' => 2284,
|
||||
'user_data' => "#cloud-config\npackages:\n - curl",
|
||||
]);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://api.vultr.com/v2/instances'
|
||||
&& $request['user_data'] === base64_encode("#cloud-config\npackages:\n - curl");
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for Vultr to replace placeholder public IP', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response([
|
||||
'instance' => [
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '123.45.67.89',
|
||||
'status' => 'active',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
$instance = $service->waitForPublicIp([
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '0.0.0.0',
|
||||
'status' => 'pending',
|
||||
], sleepMilliseconds: 0);
|
||||
|
||||
expect($service->getPublicIp($instance))->toBe('123.45.67.89');
|
||||
});
|
||||
|
||||
it('finds an instance by IPv4 or IPv6', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances*' => Http::response([
|
||||
'instances' => [
|
||||
[
|
||||
'id' => 'instance-1',
|
||||
'main_ip' => '123.45.67.89',
|
||||
'v6_main_ip' => '2001:db8::1',
|
||||
],
|
||||
],
|
||||
'meta' => ['links' => ['next' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
|
||||
expect($service->findInstanceByIp('123.45.67.89')['id'])->toBe('instance-1')
|
||||
->and($service->findInstanceByIp('2001:db8::1')['id'])->toBe('instance-1')
|
||||
->and($service->findInstanceByIp('1.2.3.4'))->toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty successful responses from Vultr API', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance-1/start' => Http::response(null, 204),
|
||||
'https://api.vultr.com/v2/instances/instance-1' => Http::response(null, 204),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
|
||||
expect($service->startInstance('instance-1'))->toBe([]);
|
||||
$service->deleteInstance('instance-1');
|
||||
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('encodes instance IDs in request paths', function () {
|
||||
Http::fake([
|
||||
'https://api.vultr.com/v2/instances/instance%2F1' => Http::response([
|
||||
'instance' => ['id' => 'instance/1'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new VultrService('fake-token');
|
||||
|
||||
expect($service->getInstance('instance/1')['id'])->toBe('instance/1');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.vultr.com/v2/instances/instance%2F1');
|
||||
});
|
||||
Reference in new issue
Block a user