mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 14:15:58 +00:00
fix(api): secure proxy config, clone volumes, and rollback tags (#11003)
This commit is contained in:
1 parent
16972f6483
commit
e560bdf9e0
43 files changed
+16229
-29
No files matched your search
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
|
||||
function currentRollbackImageTag(string $imageReference): ?string
|
||||
{
|
||||
$method = new ReflectionMethod(ApplicationsController::class, 'currentRollbackImageTag');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(null, $imageReference);
|
||||
}
|
||||
|
||||
it('extracts the current rollback image tag when the registry includes a port', function () {
|
||||
expect(currentRollbackImageTag('registry.example.com:5000/team/application:commit-sha'))
|
||||
->toBe('commit-sha');
|
||||
});
|
||||
|
||||
it('does not treat a digest as the current rollback image tag', function () {
|
||||
expect(currentRollbackImageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64)))
|
||||
->toBeNull();
|
||||
});
|
||||
@@ -57,6 +57,14 @@ function recommendedApplicationSettingsPayload(): array
|
||||
'is_gzip_enabled' => false,
|
||||
'is_stripprefix_enabled' => false,
|
||||
'is_raw_compose_deployment_enabled' => true,
|
||||
'is_log_drain_enabled' => true,
|
||||
'is_gpu_enabled' => true,
|
||||
'gpu_driver' => 'nvidia',
|
||||
'gpu_count' => '1',
|
||||
'gpu_device_ids' => '0',
|
||||
'gpu_options' => null,
|
||||
'is_consistent_container_name_enabled' => true,
|
||||
'custom_internal_name' => 'my-app-internal',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -181,3 +189,122 @@ test('raw compose deployment can only be enabled for Docker Compose applications
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('is_raw_compose_deployment_enabled');
|
||||
});
|
||||
|
||||
function advancedApplicationSettingsPayload(): array
|
||||
{
|
||||
return [
|
||||
'is_log_drain_enabled' => true,
|
||||
'is_gpu_enabled' => true,
|
||||
'gpu_driver' => 'nvidia',
|
||||
'gpu_count' => '1',
|
||||
'gpu_device_ids' => '0',
|
||||
'gpu_options' => 'capabilities=compute,utility',
|
||||
'is_consistent_container_name_enabled' => true,
|
||||
'custom_internal_name' => 'my-app-container',
|
||||
];
|
||||
}
|
||||
|
||||
test('PATCH /api/v1/applications/{uuid} updates advanced application settings', function () {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", advancedApplicationSettingsPayload())
|
||||
->assertOk();
|
||||
|
||||
$settings = $this->application->fresh()->settings;
|
||||
|
||||
foreach (advancedApplicationSettingsPayload() as $field => $value) {
|
||||
expect($settings->{$field})->toBe($value);
|
||||
}
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/applications/{uuid} updates preview_url_template and max_restart_count', function () {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'preview_url_template' => '{{pr_id}}.preview.example.com',
|
||||
'max_restart_count' => 5,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
$application = $this->application->fresh();
|
||||
|
||||
expect($application->preview_url_template)->toBe('{{pr_id}}.preview.example.com')
|
||||
->and($application->max_restart_count)->toBe(5);
|
||||
});
|
||||
|
||||
test('GET /api/v1/applications/{uuid} includes advanced settings', function () {
|
||||
$this->application->settings->update(advancedApplicationSettingsPayload());
|
||||
$this->application->update([
|
||||
'preview_url_template' => '{{pr_id}}.preview.example.com',
|
||||
'max_restart_count' => 3,
|
||||
]);
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}")
|
||||
->assertOk()
|
||||
->assertJsonPath('settings.is_log_drain_enabled', true)
|
||||
->assertJsonPath('settings.is_gpu_enabled', true)
|
||||
->assertJsonPath('settings.gpu_driver', 'nvidia')
|
||||
->assertJsonPath('settings.custom_internal_name', 'my-app-container')
|
||||
->assertJsonPath('settings.is_consistent_container_name_enabled', true)
|
||||
->assertJsonPath('preview_url_template', '{{pr_id}}.preview.example.com')
|
||||
->assertJsonPath('max_restart_count', 3);
|
||||
});
|
||||
|
||||
test('application creation accepts advanced application settings', function () {
|
||||
Queue::fake();
|
||||
|
||||
$response = $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/applications/public', array_merge([
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'git_repository' => 'https://gitlab.com/coolify/advanced-settings-test',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'autogenerate_domain' => false,
|
||||
'preview_url_template' => '{{pr_id}}.create.example.com',
|
||||
'max_restart_count' => 7,
|
||||
], advancedApplicationSettingsPayload()))
|
||||
->assertCreated();
|
||||
|
||||
$application = Application::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
$settings = $application->settings;
|
||||
|
||||
foreach (advancedApplicationSettingsPayload() as $field => $value) {
|
||||
expect($settings->{$field})->toBe($value);
|
||||
}
|
||||
|
||||
expect($application->preview_url_template)->toBe('{{pr_id}}.create.example.com')
|
||||
->and($application->max_restart_count)->toBe(7);
|
||||
});
|
||||
|
||||
test('rejects invalid max_restart_count', function () {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'max_restart_count' => -1,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('max_restart_count');
|
||||
});
|
||||
|
||||
test('rejects invalid gpu boolean settings', function () {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'is_gpu_enabled' => 'not-a-boolean',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('is_gpu_enabled');
|
||||
});
|
||||
|
||||
test('rejects swarm fields on application update', function (string $field, mixed $value) {
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
$field => $value,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors($field);
|
||||
})->with([
|
||||
'swarm_replicas' => ['swarm_replicas', 3],
|
||||
'swarm_placement_constraints' => ['swarm_placement_constraints', 'node.role==worker'],
|
||||
'is_swarm_only_worker_nodes' => ['is_swarm_only_worker_nodes', true],
|
||||
]);
|
||||
@@ -245,6 +245,79 @@ describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/destinations/{uuid}', function () {
|
||||
test('updates destination name only', function () {
|
||||
$originalNetwork = $this->destination->network;
|
||||
|
||||
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
||||
'name' => 'Renamed Destination',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'uuid' => $this->destination->uuid,
|
||||
'name' => 'Renamed Destination',
|
||||
'network' => $originalNetwork,
|
||||
'type' => 'standalone',
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]);
|
||||
|
||||
$this->destination->refresh();
|
||||
expect($this->destination->name)->toBe('Renamed Destination')
|
||||
->and($this->destination->network)->toBe($originalNetwork);
|
||||
});
|
||||
|
||||
test('requires a write token', function () {
|
||||
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
|
||||
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
||||
'name' => 'Should Fail',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects update requests from non-admin team members', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$memberToken = destinationsApiToken($member, $this->team, ['*']);
|
||||
|
||||
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
|
||||
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
||||
'name' => 'Member Rename',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects network changes and unknown fields', function () {
|
||||
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/destinations/{$this->destination->uuid}", [
|
||||
'name' => 'Valid Name',
|
||||
'network' => 'new-network',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['network']);
|
||||
});
|
||||
|
||||
test('does not update another team destination', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
|
||||
|
||||
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/destinations/{$otherDestination->uuid}", [
|
||||
'name' => 'Stolen Name',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($otherDestination->fresh()->name)->not->toBe('Stolen Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/destinations/{uuid}', function () {
|
||||
test('requires a write token', function () {
|
||||
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
|
||||
['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->bearerToken = environmentUpdateApiToken($this->user, $this->team, ['*']);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']);
|
||||
});
|
||||
|
||||
function environmentUpdateApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function environmentUpdateApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'environment-update-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
describe('PATCH /api/v1/projects/{uuid}/environments/{environment_name_or_uuid}', function () {
|
||||
test('updates environment name and description by uuid', function () {
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
|
||||
'name' => 'staging',
|
||||
'description' => 'Staging environment',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'uuid' => $this->environment->uuid,
|
||||
'name' => 'staging',
|
||||
'description' => 'Staging environment',
|
||||
]);
|
||||
|
||||
$this->environment->refresh();
|
||||
expect($this->environment->name)->toBe('staging')
|
||||
->and($this->environment->description)->toBe('Staging environment');
|
||||
});
|
||||
|
||||
test('updates environment by name path segment', function () {
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->name}", [
|
||||
'description' => 'Updated description only',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'uuid' => $this->environment->uuid,
|
||||
'name' => $this->environment->name,
|
||||
'description' => 'Updated description only',
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires a write token', function () {
|
||||
$readOnlyToken = environmentUpdateApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($readOnlyToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
|
||||
'name' => 'should-fail',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
expect($this->environment->fresh()->name)->toBe($this->environment->name);
|
||||
});
|
||||
|
||||
test('rejects update requests from non-admin team members', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$memberToken = environmentUpdateApiToken($member, $this->team, ['*']);
|
||||
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($memberToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
|
||||
'name' => 'member-rename',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
expect($this->environment->fresh()->name)->toBe($this->environment->name);
|
||||
});
|
||||
|
||||
test('rejects unknown fields', function () {
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
|
||||
'name' => 'valid-name',
|
||||
'unexpected' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['unexpected']);
|
||||
});
|
||||
|
||||
test('returns 404 for another team project', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = $otherProject->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$otherProject->uuid}/environments/{$otherEnvironment->uuid}", [
|
||||
'name' => 'stolen',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns 409 when renaming to an existing environment name', function () {
|
||||
$other = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
'name' => 'already-taken',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}", [
|
||||
'name' => $other->name,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409)
|
||||
->assertJson(['message' => 'Environment with this name already exists.']);
|
||||
});
|
||||
|
||||
test('returns 404 for missing environment', function () {
|
||||
$response = $this->withHeaders(environmentUpdateApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/environments/missing-env", [
|
||||
'name' => 'new-name',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ScheduledTaskJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\ScheduledTask;
|
||||
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\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
|
||||
['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]);
|
||||
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $this->user->tokens()->create([
|
||||
'name' => 'lifecycle-api-test',
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => ['*'],
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->headers = [
|
||||
'Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = $this->project->environments()->first()
|
||||
?? 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(),
|
||||
'name' => 'source-app',
|
||||
]);
|
||||
$this->application->settings->fill([
|
||||
'is_container_label_readonly_enabled' => false,
|
||||
])->save();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/clone', function () {
|
||||
test('clones an application and returns the new uuid', function () {
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'name' => 'cloned-app',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('message', 'Application cloned.')
|
||||
->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$newUuid = $response->json('uuid');
|
||||
expect($newUuid)->not->toBe($this->application->uuid);
|
||||
|
||||
$cloned = Application::where('uuid', $newUuid)->first();
|
||||
expect($cloned)->not->toBeNull()
|
||||
->and($cloned->name)->toBe('cloned-app')
|
||||
->and($cloned->environment_id)->toBe($this->application->environment_id)
|
||||
->and($cloned->destination_id)->toBe($this->destination->id);
|
||||
});
|
||||
|
||||
test('returns 404 for another team destination', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/clone", [
|
||||
'destination_uuid' => $otherDestination->uuid,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns 404 for another team application', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = $otherProject->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$otherApp = Application::factory()->create([
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$otherApp->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/clone', function () {
|
||||
test('clones a database and returns the new uuid', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'source-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/databases/{$database->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'name' => 'cloned-db',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('message', 'Database cloned.');
|
||||
|
||||
$cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->first();
|
||||
expect($cloned)->not->toBeNull()
|
||||
->and($cloned->name)->toBe('cloned-db')
|
||||
->and($cloned->environment_id)->toBe($database->environment_id)
|
||||
->and($cloned->destination_id)->toBe($this->destination->id)
|
||||
->and(str($cloned->status)->startsWith('exited'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('creates renamed volumes when cloning a database with clone_volumes', function () {
|
||||
// Prevent remote stop/clone/start from running in unit tests.
|
||||
Bus::fake();
|
||||
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'source-db-volumes',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
// Factory/create hooks may already create a data volume.
|
||||
if ($database->persistentStorages()->count() === 0) {
|
||||
LocalPersistentVolume::create([
|
||||
'name' => 'postgres-data-'.$database->uuid,
|
||||
'mount_path' => '/var/lib/postgresql/data',
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => $database->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
$sourceVolumeCount = $database->persistentStorages()->count();
|
||||
expect($sourceVolumeCount)->toBeGreaterThan(0);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/databases/{$database->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'name' => 'cloned-db-volumes',
|
||||
'clone_volumes' => true,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$cloned = StandalonePostgresql::where('uuid', $response->json('uuid'))->firstOrFail();
|
||||
expect($cloned->persistentStorages()->count())->toBe($sourceVolumeCount)
|
||||
->and($cloned->persistentStorages()->first()->name)->not->toBe($database->persistentStorages()->first()->name)
|
||||
->and($cloned->persistentStorages()->first()->name)->toContain($cloned->uuid);
|
||||
});
|
||||
|
||||
test('returns 404 for another team database', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = $otherProject->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$otherDatabase = StandalonePostgresql::create([
|
||||
'name' => 'other-db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/databases/{$otherDatabase->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/clone', function () {
|
||||
test('clones a service and parses applications from compose', function () {
|
||||
$service = Service::factory()->create([
|
||||
'name' => 'source-service',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'server_id' => $this->server->id,
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
'compose_parsing_version' => '5',
|
||||
]);
|
||||
$service->parse();
|
||||
expect($service->applications()->count())->toBeGreaterThan(0);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/services/{$service->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
'name' => 'cloned-service',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('message', 'Service cloned.');
|
||||
|
||||
$cloned = Service::where('uuid', $response->json('uuid'))->first();
|
||||
expect($cloned)->not->toBeNull()
|
||||
->and($cloned->name)->toBe('cloned-service')
|
||||
->and($cloned->environment_id)->toBe($service->environment_id)
|
||||
->and($cloned->applications()->count())->toBe($service->applications()->count());
|
||||
});
|
||||
|
||||
test('returns 404 for another team service', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnvironment = $otherProject->environments()->first()
|
||||
?? Environment::factory()->create(['project_id' => $otherProject->id]);
|
||||
$otherService = Service::factory()->create([
|
||||
'environment_id' => $otherEnvironment->id,
|
||||
'destination_id' => $otherDestination->id,
|
||||
'destination_type' => $otherDestination->getMorphClass(),
|
||||
'server_id' => $otherServer->id,
|
||||
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n",
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/services/{$otherService->uuid}/clone", [
|
||||
'destination_uuid' => $this->destination->uuid,
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', function () {
|
||||
test('dispatches ScheduledTaskJob for an application task', function () {
|
||||
Queue::fake();
|
||||
|
||||
$task = ScheduledTask::factory()->create([
|
||||
'application_id' => $this->application->id,
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'nightly',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/{$task->uuid}/execute");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('message', 'Scheduled task execution queued.');
|
||||
|
||||
Queue::assertPushed(ScheduledTaskJob::class, fn (ScheduledTaskJob $job) => $job->task->is($task));
|
||||
});
|
||||
|
||||
test('returns 404 for unknown task', function () {
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/scheduled-tasks/missing-task/execute");
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid} health check fields', function () {
|
||||
test('can update database health_check fields', function () {
|
||||
$database = StandalonePostgresql::create([
|
||||
'name' => 'pg-health',
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'postgres_password' => 'secret',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/databases/{$database->uuid}", [
|
||||
'health_check_enabled' => false,
|
||||
'health_check_interval' => 30,
|
||||
'health_check_timeout' => 10,
|
||||
'health_check_retries' => 3,
|
||||
'health_check_start_period' => 20,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$database->refresh();
|
||||
expect($database->health_check_enabled)->toBeFalse()
|
||||
->and($database->health_check_interval)->toBe(30)
|
||||
->and($database->health_check_timeout)->toBe(10)
|
||||
->and($database->health_check_retries)->toBe(3)
|
||||
->and($database->health_check_start_period)->toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud-init scripts CRUD', function () {
|
||||
test('creates lists updates and deletes cloud-init scripts for the team', function () {
|
||||
$create = $this->withHeaders($this->headers)
|
||||
->postJson('/api/v1/cloud-init-scripts', [
|
||||
'name' => 'bootstrap',
|
||||
'script' => "#!/bin/bash\necho hello",
|
||||
]);
|
||||
|
||||
$create->assertCreated()
|
||||
->assertJsonPath('name', 'bootstrap')
|
||||
->assertJsonStructure(['uuid', 'name']);
|
||||
|
||||
$uuid = $create->json('uuid');
|
||||
|
||||
$list = $this->withHeaders($this->headers)
|
||||
->getJson('/api/v1/cloud-init-scripts');
|
||||
$list->assertOk();
|
||||
expect(collect($list->json())->pluck('uuid'))->toContain($uuid);
|
||||
|
||||
$show = $this->withHeaders($this->headers)
|
||||
->getJson("/api/v1/cloud-init-scripts/{$uuid}");
|
||||
$show->assertOk()->assertJsonPath('name', 'bootstrap');
|
||||
|
||||
$update = $this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/cloud-init-scripts/{$uuid}", [
|
||||
'name' => 'bootstrap-v2',
|
||||
]);
|
||||
$update->assertOk()->assertJsonPath('name', 'bootstrap-v2');
|
||||
|
||||
$delete = $this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/cloud-init-scripts/{$uuid}");
|
||||
$delete->assertOk();
|
||||
|
||||
expect(CloudInitScript::where('uuid', $uuid)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns 404 for another team cloud-init script', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$script = CloudInitScript::create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'name' => 'other',
|
||||
'script' => "#!/bin/bash\necho other",
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->getJson("/api/v1/cloud-init-scripts/{$script->uuid}")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->patchJson("/api/v1/cloud-init-scripts/{$script->uuid}", ['name' => 'nope'])
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->deleteJson("/api/v1/cloud-init-scripts/{$script->uuid}")
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Application multi-destination cross-team', function () {
|
||||
test('returns 404 when attaching another team destination', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
|
||||
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->postJson("/api/v1/applications/{$this->application->uuid}/destinations", [
|
||||
'destination_uuid' => $otherDestination->uuid,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($this->application->fresh()->additional_networks)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('lists primary destination', function () {
|
||||
$response = $this->withHeaders($this->headers)
|
||||
->getJson("/api/v1/applications/{$this->application->uuid}/destinations");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json())->toHaveCount(1)
|
||||
->and($response->json('0.uuid'))->toBe($this->destination->uuid)
|
||||
->and($response->json('0.is_primary'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
use App\Models\DiscordNotificationSettings;
|
||||
use App\Models\EmailNotificationSettings;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PushoverNotificationSettings;
|
||||
use App\Models\SlackNotificationSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\TelegramNotificationSettings;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookNotificationSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
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;
|
||||
$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;
|
||||
});
|
||||
|
||||
function authHeaders(string $token): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('GET /api/v1/notifications/*', function () {
|
||||
test('returns email notification settings for the current team', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/notifications/email');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'team_id' => $this->team->id,
|
||||
'smtp_enabled' => false,
|
||||
]);
|
||||
$response->assertJsonStructure([
|
||||
'team_id',
|
||||
'smtp_enabled',
|
||||
'deployment_failure_email_notifications',
|
||||
'use_instance_email_settings',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns settings for every notification channel', function (string $channel) {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/notifications/{$channel}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('team_id', $this->team->id);
|
||||
})->with([
|
||||
'email',
|
||||
'discord',
|
||||
'slack',
|
||||
'telegram',
|
||||
'pushover',
|
||||
'webhook',
|
||||
]);
|
||||
|
||||
test('hides encrypted secrets without read:sensitive ability', function () {
|
||||
$this->team->discordNotificationSettings->update([
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/hidden/secret',
|
||||
'discord_enabled' => true,
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(authHeaders($readToken))
|
||||
->getJson('/api/v1/notifications/discord');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonMissingPath('discord_webhook_url');
|
||||
expect($response->getContent())->not->toContain('hidden/secret');
|
||||
});
|
||||
|
||||
test('includes encrypted secrets with read:sensitive ability for admins', function () {
|
||||
$this->team->discordNotificationSettings->update([
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token',
|
||||
'discord_enabled' => true,
|
||||
]);
|
||||
|
||||
$sensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(authHeaders($sensitiveToken))
|
||||
->getJson('/api/v1/notifications/discord');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/visible/secret-token',
|
||||
]);
|
||||
});
|
||||
|
||||
test('member with read token can view settings but not secrets', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->team->emailNotificationSettings->update([
|
||||
'smtp_password' => 'super-secret-password',
|
||||
'smtp_enabled' => true,
|
||||
]);
|
||||
|
||||
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(authHeaders($memberToken))
|
||||
->getJson('/api/v1/notifications/email');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('smtp_enabled', true);
|
||||
$response->assertJsonMissingPath('smtp_password');
|
||||
expect($response->getContent())->not->toContain('super-secret-password');
|
||||
});
|
||||
|
||||
test('member cannot use read:sensitive token ability', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$memberToken = $member->createToken('member-sensitive', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$this->withHeaders(authHeaders($memberToken))
|
||||
->getJson('/api/v1/notifications/email')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/notifications/email')
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
test('firstOrCreate restores missing channel settings', function () {
|
||||
DiscordNotificationSettings::query()->where('team_id', $this->team->id)->delete();
|
||||
|
||||
expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeFalse();
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/notifications/discord');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('team_id', $this->team->id);
|
||||
expect(DiscordNotificationSettings::query()->where('team_id', $this->team->id)->exists())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/notifications/*', function () {
|
||||
test('updates email notification settings', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'alerts@example.com',
|
||||
'smtp_host' => 'smtp.example.com',
|
||||
'smtp_port' => 587,
|
||||
'smtp_encryption' => 'starttls',
|
||||
'deployment_failure_email_notifications' => false,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('smtp_enabled', true);
|
||||
$response->assertJsonPath('deployment_failure_email_notifications', false);
|
||||
|
||||
$settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
|
||||
expect($settings->smtp_enabled)->toBeTrue()
|
||||
->and($settings->smtp_from_address)->toBe('alerts@example.com')
|
||||
->and($settings->smtp_host)->toBe('smtp.example.com')
|
||||
->and($settings->smtp_port)->toBe(587)
|
||||
->and($settings->deployment_failure_email_notifications)->toBeFalse();
|
||||
});
|
||||
|
||||
test('updates discord notification settings', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/discord', [
|
||||
'discord_enabled' => true,
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/123/abc',
|
||||
'discord_ping_enabled' => false,
|
||||
'deployment_success_discord_notifications' => true,
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath('discord_enabled', true);
|
||||
$response->assertJsonPath('discord_ping_enabled', false);
|
||||
|
||||
$settings = DiscordNotificationSettings::query()->where('team_id', $this->team->id)->first();
|
||||
expect($settings->discord_enabled)->toBeTrue()
|
||||
->and($settings->discord_webhook_url)->toBe('https://discord.com/api/webhooks/123/abc')
|
||||
->and($settings->discord_ping_enabled)->toBeFalse()
|
||||
->and($settings->deployment_success_discord_notifications)->toBeTrue();
|
||||
});
|
||||
|
||||
test('updates slack, telegram, pushover, and webhook channels', function (string $channel, array $payload, string $modelClass, string $enabledField) {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/notifications/{$channel}", $payload);
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonPath($enabledField, true);
|
||||
|
||||
$settings = $modelClass::query()->where('team_id', $this->team->id)->first();
|
||||
expect($settings->{$enabledField})->toBeTrue();
|
||||
})->with([
|
||||
'slack' => [
|
||||
'slack',
|
||||
[
|
||||
'slack_enabled' => true,
|
||||
'slack_webhook_url' => 'https://hooks.slack.com/services/T00/B00/xxx',
|
||||
'deployment_failure_slack_notifications' => true,
|
||||
],
|
||||
SlackNotificationSettings::class,
|
||||
'slack_enabled',
|
||||
],
|
||||
'telegram' => [
|
||||
'telegram',
|
||||
[
|
||||
'telegram_enabled' => true,
|
||||
'telegram_token' => '123456:ABC-DEF',
|
||||
'telegram_chat_id' => '-100123',
|
||||
'deployment_failure_telegram_notifications' => true,
|
||||
],
|
||||
TelegramNotificationSettings::class,
|
||||
'telegram_enabled',
|
||||
],
|
||||
'pushover' => [
|
||||
'pushover',
|
||||
[
|
||||
'pushover_enabled' => true,
|
||||
'pushover_user_key' => 'user-key',
|
||||
'pushover_api_token' => 'api-token',
|
||||
'deployment_failure_pushover_notifications' => true,
|
||||
],
|
||||
PushoverNotificationSettings::class,
|
||||
'pushover_enabled',
|
||||
],
|
||||
'webhook' => [
|
||||
'webhook',
|
||||
[
|
||||
'webhook_enabled' => true,
|
||||
'webhook_url' => 'https://example.com/hooks/coolify',
|
||||
'deployment_failure_webhook_notifications' => true,
|
||||
],
|
||||
WebhookNotificationSettings::class,
|
||||
'webhook_enabled',
|
||||
],
|
||||
]);
|
||||
|
||||
test('rejects unknown fields with 422', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_enabled' => true,
|
||||
'not_a_real_field' => 'nope',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('errors.not_a_real_field.0', 'This field is not allowed.');
|
||||
});
|
||||
|
||||
test('rejects team_id mass assignment attempts', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/discord', [
|
||||
'discord_enabled' => true,
|
||||
'team_id' => $otherTeam->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('errors.team_id.0', 'This field is not allowed.');
|
||||
});
|
||||
|
||||
test('requires write ability', function () {
|
||||
$readToken = $this->user->createToken('read-only', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(authHeaders($readToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('forbids members from updating notification settings', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-write', ['read', 'write'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(authHeaders($memberToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('rejects empty json body', function () {
|
||||
$response = $this->withHeaders(authHeaders($this->bearerToken))
|
||||
->patchJson('/api/v1/notifications/email', []);
|
||||
|
||||
$response->assertStatus(400);
|
||||
});
|
||||
|
||||
test('does not leak updates across teams', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $otherTeam]);
|
||||
$otherToken = $otherUser->createToken('other-token', ['*'])->plainTextToken;
|
||||
|
||||
$this->withHeaders(authHeaders($otherToken))
|
||||
->patchJson('/api/v1/notifications/email', [
|
||||
'smtp_enabled' => true,
|
||||
'smtp_from_address' => 'other@example.com',
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$thisTeamSettings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
|
||||
$otherSettings = EmailNotificationSettings::query()->where('team_id', $otherTeam->id)->first();
|
||||
|
||||
expect($thisTeamSettings->smtp_enabled)->toBeFalse()
|
||||
->and($otherSettings->smtp_enabled)->toBeTrue()
|
||||
->and($otherSettings->smtp_from_address)->toBe('other@example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
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;
|
||||
$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;
|
||||
});
|
||||
|
||||
function createS3StorageForTeam(Team $team, array $overrides = []): S3Storage
|
||||
{
|
||||
return S3Storage::create(array_merge([
|
||||
'team_id' => $team->id,
|
||||
'name' => 'Team S3 Storage',
|
||||
'description' => 'Test storage',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-access-key',
|
||||
'secret' => 'test-secret-key',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'is_usable' => false,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
function validS3StoragePayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'name' => 'My S3 Storage',
|
||||
'description' => 'Backup storage',
|
||||
'endpoint' => 'https://s3.amazonaws.com',
|
||||
'bucket' => 'coolify-backups',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'AKIAEXAMPLEKEY',
|
||||
'secret' => 'example-secret-value',
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
describe('GET /api/v1/s3-storages', function () {
|
||||
test('lists all s3 storages for the team', function () {
|
||||
createS3StorageForTeam($this->team, ['name' => 'Storage One', 'bucket' => 'bucket-one']);
|
||||
createS3StorageForTeam($this->team, ['name' => 'Storage Two', 'bucket' => 'bucket-two']);
|
||||
createS3StorageForTeam($this->team, ['name' => 'Storage Three', 'bucket' => 'bucket-three']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(3);
|
||||
$response->assertJsonStructure([
|
||||
'*' => ['uuid', 'name', 'description', 'endpoint', 'bucket', 'region', 'is_usable', 'team_id', 'created_at', 'updated_at'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not include storages from other teams', function () {
|
||||
createS3StorageForTeam($this->team);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage', 'bucket' => 'other-bucket']);
|
||||
createS3StorageForTeam($otherTeam, ['name' => 'Other Team Storage 2', 'bucket' => 'other-bucket-2']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->getJson('/api/v1/s3-storages');
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read token does not include key and secret values', function () {
|
||||
createS3StorageForTeam($this->team, [
|
||||
'key' => 'hidden-access-key',
|
||||
'secret' => 'hidden-secret-key',
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages');
|
||||
|
||||
$response->assertSuccessful();
|
||||
expect($response->getContent())->not->toContain('hidden-access-key');
|
||||
expect($response->getContent())->not->toContain('hidden-secret-key');
|
||||
expect($response->getContent())->not->toContain('"key":');
|
||||
expect($response->getContent())->not->toContain('"secret":');
|
||||
});
|
||||
|
||||
test('read sensitive token includes key and secret values', function () {
|
||||
createS3StorageForTeam($this->team, [
|
||||
'key' => 'visible-access-key',
|
||||
'secret' => 'visible-secret-key',
|
||||
]);
|
||||
|
||||
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readSensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'key' => 'visible-access-key',
|
||||
'secret' => 'visible-secret-key',
|
||||
]);
|
||||
});
|
||||
|
||||
test('root token includes key and secret values', function () {
|
||||
createS3StorageForTeam($this->team, [
|
||||
'key' => 'root-access-key',
|
||||
'secret' => 'root-secret-key',
|
||||
]);
|
||||
|
||||
$rootToken = $this->user->createToken('root-token', ['root'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$rootToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'key' => 'root-access-key',
|
||||
'secret' => 'root-secret-key',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/s3-storages/{uuid}', function () {
|
||||
test('gets s3 storage by UUID', function () {
|
||||
$storage = createS3StorageForTeam($this->team, [
|
||||
'name' => 'Primary Backup',
|
||||
'bucket' => 'primary-backup',
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonFragment([
|
||||
'name' => 'Primary Backup',
|
||||
'bucket' => 'primary-backup',
|
||||
'region' => 'us-east-1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/s3-storages/non-existent-uuid');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('cannot access storage from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$storage = createS3StorageForTeam($otherTeam);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('read token does not include key and secret by UUID', function () {
|
||||
$storage = createS3StorageForTeam($this->team, [
|
||||
'key' => 'hidden-detail-key',
|
||||
'secret' => 'hidden-detail-secret',
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
expect($response->getContent())->not->toContain('hidden-detail-key');
|
||||
expect($response->getContent())->not->toContain('hidden-detail-secret');
|
||||
});
|
||||
|
||||
test('read sensitive token includes key and secret by UUID', function () {
|
||||
$storage = createS3StorageForTeam($this->team, [
|
||||
'key' => 'visible-detail-key',
|
||||
'secret' => 'visible-detail-secret',
|
||||
]);
|
||||
|
||||
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readSensitiveToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJsonFragment([
|
||||
'key' => 'visible-detail-key',
|
||||
'secret' => 'visible-detail-secret',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/s3-storages', function () {
|
||||
test('creates an s3 storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', validS3StoragePayload());
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid']);
|
||||
|
||||
$this->assertDatabaseHas('s3_storages', [
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'My S3 Storage',
|
||||
'bucket' => 'coolify-backups',
|
||||
'region' => 'us-east-1',
|
||||
]);
|
||||
});
|
||||
|
||||
test('validates name is required', function () {
|
||||
$payload = validS3StoragePayload();
|
||||
unset($payload['name']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', $payload);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['name']);
|
||||
});
|
||||
|
||||
test('validates key is required', function () {
|
||||
$payload = validS3StoragePayload();
|
||||
unset($payload['key']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', $payload);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['key']);
|
||||
});
|
||||
|
||||
test('validates secret is required', function () {
|
||||
$payload = validS3StoragePayload();
|
||||
unset($payload['secret']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', $payload);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['secret']);
|
||||
});
|
||||
|
||||
test('validates bucket format', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
|
||||
'bucket' => 'Invalid_Bucket',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['bucket']);
|
||||
});
|
||||
|
||||
test('rejects unsafe endpoints', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
|
||||
'endpoint' => 'http://127.0.0.1:9000',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['endpoint']);
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/s3-storages', validS3StoragePayload([
|
||||
'invalid_field' => 'invalid_value',
|
||||
]));
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/s3-storages/{uuid}', function () {
|
||||
test('updates s3 storage name', function () {
|
||||
$storage = createS3StorageForTeam($this->team, ['name' => 'Old Name']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('s3_storages', [
|
||||
'uuid' => $storage->uuid,
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
});
|
||||
|
||||
test('updates multiple fields', function () {
|
||||
$storage = createS3StorageForTeam($this->team);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
|
||||
'name' => 'Updated Storage',
|
||||
'region' => 'eu-west-1',
|
||||
'bucket' => 'updated-bucket',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('s3_storages', [
|
||||
'uuid' => $storage->uuid,
|
||||
'name' => 'Updated Storage',
|
||||
'region' => 'eu-west-1',
|
||||
'bucket' => 'updated-bucket',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects empty body', function () {
|
||||
$storage = createS3StorageForTeam($this->team);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", []);
|
||||
|
||||
$response->assertStatus(400);
|
||||
$response->assertJson([
|
||||
'message' => 'Invalid request.',
|
||||
'error' => 'Invalid JSON.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot update storage from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$storage = createS3StorageForTeam($otherTeam);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('rejects extra fields on update', function () {
|
||||
$storage = createS3StorageForTeam($this->team);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/s3-storages/{$storage->uuid}", [
|
||||
'name' => 'New Name',
|
||||
'team_id' => 999,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/s3-storages/{uuid}', function () {
|
||||
test('deletes s3 storage', function () {
|
||||
$storage = createS3StorageForTeam($this->team);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['message' => 'S3 storage deleted.']);
|
||||
|
||||
$this->assertDatabaseMissing('s3_storages', [
|
||||
'uuid' => $storage->uuid,
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot delete storage from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$storage = createS3StorageForTeam($otherTeam);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson("/api/v1/s3-storages/{$storage->uuid}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->deleteJson('/api/v1/s3-storages/non-existent-uuid');
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/s3-storages/{uuid}/validate', function () {
|
||||
test('validates a working s3 storage connection', function () {
|
||||
$storage = createS3StorageForTeam($this->team);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->expects('files')->once()->andReturn([]);
|
||||
Storage::expects('build')->once()->andReturn($disk);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'valid' => true,
|
||||
'message' => 'S3 storage connection is valid.',
|
||||
]);
|
||||
|
||||
expect($storage->fresh()->is_usable)->toBeTrue();
|
||||
});
|
||||
|
||||
test('detects an invalid s3 storage connection', function () {
|
||||
$storage = createS3StorageForTeam($this->team, ['is_usable' => true]);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->expects('files')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Access Denied'));
|
||||
Storage::expects('build')->once()->andReturn($disk);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'valid' => false,
|
||||
'message' => 'Access Denied',
|
||||
]);
|
||||
|
||||
expect($storage->fresh()->is_usable)->toBeFalse();
|
||||
});
|
||||
|
||||
test('cannot validate storage from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$storage = createS3StorageForTeam($otherTeam);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate");
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
test('writes an audit log entry when validating storage', function () {
|
||||
$storage = createS3StorageForTeam($this->team, ['name' => 'Audit Storage']);
|
||||
|
||||
$disk = Mockery::mock();
|
||||
$disk->expects('files')->once()->andReturn([]);
|
||||
Storage::expects('build')->once()->andReturn($disk);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('api.s3_storage.validated', Mockery::on(function (array $context) use ($storage) {
|
||||
return $context['s3_storage_uuid'] === $storage->uuid
|
||||
&& $context['s3_storage_name'] === 'Audit Storage'
|
||||
&& $context['valid'] === true;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
$this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/s3-storages/{$storage->uuid}/validate")
|
||||
->assertOk();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
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\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
|
||||
['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->bearerToken = serverProxyApiToken($this->user, $this->team, ['*']);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$this->server->proxy->set('status', 'exited');
|
||||
$this->server->proxy->redirect_enabled = true;
|
||||
$this->server->save();
|
||||
});
|
||||
|
||||
function serverProxyApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function serverProxyApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'server-proxy-api-test-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
test('GET /api/v1/servers/{uuid}/proxy returns proxy settings without configuration when none is stored', function () {
|
||||
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
|
||||
->assertOk()
|
||||
->assertJsonPath('proxy_type', ProxyTypes::TRAEFIK->value)
|
||||
->assertJsonPath('redirect_enabled', true)
|
||||
->assertJsonPath('redirect_url', null)
|
||||
->assertJsonPath('generate_exact_labels', false)
|
||||
->assertJsonPath('configuration', null);
|
||||
});
|
||||
|
||||
test('GET /api/v1/servers/{uuid}/proxy omits stored configuration without read:sensitive', function () {
|
||||
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
|
||||
$this->server->save();
|
||||
|
||||
// '*' tokens grant all abilities including read:sensitive; use a read-only token.
|
||||
$readToken = serverProxyApiToken($this->user, $this->team, ['read']);
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($readToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
|
||||
->assertOk()
|
||||
->assertJsonMissingPath('configuration');
|
||||
});
|
||||
|
||||
test('GET /api/v1/servers/{uuid}/proxy returns stored configuration with read:sensitive for admins', function () {
|
||||
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
|
||||
$this->server->save();
|
||||
|
||||
$sensitiveToken = serverProxyApiToken($this->user, $this->team, ['read', 'read:sensitive']);
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/proxy")
|
||||
->assertOk()
|
||||
->assertJsonPath('configuration', $compose);
|
||||
});
|
||||
|
||||
test('GET /api/v1/servers/{uuid}/proxy hides configuration from non-admin users with read:sensitive', function () {
|
||||
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
$this->server->proxy->set('last_saved_proxy_configuration', $compose);
|
||||
$this->server->save();
|
||||
|
||||
// ApiSensitiveData requires admin/owner of the token team even with read:sensitive.
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = serverProxyApiToken($member, $this->team, ['read', 'read:sensitive']);
|
||||
|
||||
// Members may be forbidden from viewing servers via policy; when allowed, config must still be hidden.
|
||||
$response = $this->withHeaders(serverProxyApiHeaders($memberToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/proxy");
|
||||
|
||||
if ($response->status() === 200) {
|
||||
$response->assertJsonMissingPath('configuration');
|
||||
} else {
|
||||
$response->assertForbidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('GET /api/v1/servers/{uuid}/proxy does not expose another team server', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/servers/{$otherServer->uuid}/proxy")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/servers/{uuid}/proxy updates redirect and label settings without SSH', function () {
|
||||
// Factory servers are not reachable, so setupDefaultRedirect is skipped.
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
|
||||
'redirect_enabled' => false,
|
||||
'redirect_url' => null,
|
||||
'generate_exact_labels' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('redirect_enabled', false)
|
||||
->assertJsonPath('redirect_url', null)
|
||||
->assertJsonPath('generate_exact_labels', true);
|
||||
|
||||
$server = $this->server->fresh();
|
||||
|
||||
expect((bool) data_get($server->proxy, 'redirect_enabled'))->toBeFalse()
|
||||
->and(data_get($server->proxy, 'redirect_url'))->toBeNull()
|
||||
->and((bool) $server->settings->generate_exact_labels)->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/servers/{uuid}/proxy rejects unknown fields', function () {
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
|
||||
'redirect_enabled' => true,
|
||||
'unknown_field' => 'nope',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.unknown_field.0', 'This field is not allowed.');
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/servers/{uuid}/proxy rejects invalid proxy type', function () {
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
|
||||
'proxy_type' => 'haproxy',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('errors.proxy_type.0', 'Invalid proxy type.');
|
||||
});
|
||||
|
||||
test('PATCH /api/v1/servers/{uuid}/proxy can change proxy type asynchronously', function () {
|
||||
StartProxy::shouldRun()->andReturn('OK');
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", [
|
||||
'proxy_type' => 'caddy',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('proxy_type', ProxyTypes::CADDY->value);
|
||||
|
||||
expect($this->server->fresh()->proxyType())->toBe(ProxyTypes::CADDY->value);
|
||||
});
|
||||
|
||||
test('PUT /api/v1/servers/{uuid}/proxy/configuration saves base64 configuration via action', function () {
|
||||
$compose = "services:\n traefik:\n image: traefik:v3.5\n";
|
||||
|
||||
SaveProxyConfiguration::shouldRun()
|
||||
->once()
|
||||
->withArgs(function (Server $server, string $configuration) use ($compose) {
|
||||
return $server->is($this->server) && $configuration === $compose;
|
||||
});
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
|
||||
'configuration' => base64_encode($compose),
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Proxy configuration saved.');
|
||||
});
|
||||
|
||||
test('PUT /api/v1/servers/{uuid}/proxy/configuration rejects missing configuration', function () {
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", [
|
||||
'foo' => 'bar',
|
||||
])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('POST /api/v1/servers/{uuid}/proxy/restart queues RestartProxyJob', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->withHeaders(serverProxyApiHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Proxy restart queued.');
|
||||
|
||||
Queue::assertPushed(
|
||||
RestartProxyJob::class,
|
||||
fn (RestartProxyJob $job): bool => $job->server->is($this->server)
|
||||
);
|
||||
});
|
||||
|
||||
test('proxy endpoints require authentication', function () {
|
||||
$this->getJson("/api/v1/servers/{$this->server->uuid}/proxy")->assertUnauthorized();
|
||||
$this->patchJson("/api/v1/servers/{$this->server->uuid}/proxy", ['redirect_enabled' => false])->assertUnauthorized();
|
||||
$this->putJson("/api/v1/servers/{$this->server->uuid}/proxy/configuration", ['configuration' => 'x'])->assertUnauthorized();
|
||||
$this->postJson("/api/v1/servers/{$this->server->uuid}/proxy/restart")->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\DockerCleanupJob;
|
||||
use App\Models\DockerCleanupExecution;
|
||||
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\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
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->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'ip' => '10.0.0.10',
|
||||
]);
|
||||
|
||||
$otherTeam = Team::factory()->create();
|
||||
$this->otherServer = Server::factory()->create([
|
||||
'team_id' => $otherTeam->id,
|
||||
'ip' => '10.0.0.20',
|
||||
]);
|
||||
|
||||
$this->token = $this->user->createToken('server-subsystems', ['*'])->plainTextToken;
|
||||
});
|
||||
|
||||
function serverSubsystemsHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.test()->token,
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('Docker cleanup API', function () {
|
||||
test('GET returns docker cleanup settings for own team server', function () {
|
||||
$this->server->settings->update([
|
||||
'docker_cleanup_frequency' => '0 0 * * *',
|
||||
'docker_cleanup_threshold' => 25,
|
||||
'force_docker_cleanup' => true,
|
||||
'delete_unused_volumes' => true,
|
||||
'delete_unused_networks' => false,
|
||||
'disable_application_image_retention' => true,
|
||||
]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup")
|
||||
->assertOk()
|
||||
->assertJsonPath('docker_cleanup_threshold', 25)
|
||||
->assertJsonPath('force_docker_cleanup', true)
|
||||
->assertJsonPath('delete_unused_volumes', true)
|
||||
->assertJsonPath('disable_application_image_retention', true);
|
||||
});
|
||||
|
||||
test('PATCH updates docker cleanup settings for own team server', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup", [
|
||||
'docker_cleanup_frequency' => '0 */6 * * *',
|
||||
'docker_cleanup_threshold' => 42,
|
||||
'force_docker_cleanup' => true,
|
||||
'delete_unused_volumes' => true,
|
||||
'delete_unused_networks' => true,
|
||||
'disable_application_image_retention' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('docker_cleanup_threshold', 42)
|
||||
->assertJsonPath('force_docker_cleanup', true);
|
||||
|
||||
$settings = $this->server->settings->fresh();
|
||||
expect($settings->docker_cleanup_threshold)->toBe(42)
|
||||
->and((bool) $settings->force_docker_cleanup)->toBeTrue()
|
||||
->and((bool) $settings->delete_unused_volumes)->toBeTrue()
|
||||
->and((bool) $settings->delete_unused_networks)->toBeTrue()
|
||||
->and((bool) $settings->disable_application_image_retention)->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST run dispatches DockerCleanupJob for own team server', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->server->settings->update([
|
||||
'delete_unused_volumes' => true,
|
||||
'delete_unused_networks' => false,
|
||||
]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/run")
|
||||
->assertOk()
|
||||
->assertJsonPath('message', fn ($message) => str_contains($message, 'Manual cleanup job started'));
|
||||
|
||||
Queue::assertPushed(DockerCleanupJob::class, function (DockerCleanupJob $job) {
|
||||
return $job->server->is($this->server)
|
||||
&& $job->manualCleanup === true
|
||||
&& $job->deleteUnusedVolumes === true
|
||||
&& $job->deleteUnusedNetworks === false;
|
||||
});
|
||||
});
|
||||
|
||||
test('GET executions lists recent cleanup runs for own team server', function () {
|
||||
$execution = DockerCleanupExecution::create([
|
||||
'server_id' => $this->server->id,
|
||||
'status' => 'success',
|
||||
'message' => 'Cleanup completed',
|
||||
]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/docker-cleanup/executions")
|
||||
->assertOk()
|
||||
->assertJsonCount(1)
|
||||
->assertJsonPath('0.uuid', $execution->uuid)
|
||||
->assertJsonPath('0.status', 'success');
|
||||
});
|
||||
|
||||
test('other-team docker cleanup endpoints return 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup", [
|
||||
'docker_cleanup_threshold' => 50,
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/run")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/docker-cleanup/executions")
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Log drains API', function () {
|
||||
test('GET returns log drain settings and hides secrets without read:sensitive', function () {
|
||||
$this->server->settings->update([
|
||||
'is_logdrain_axiom_enabled' => false,
|
||||
'logdrain_axiom_dataset_name' => 'coolify-logs',
|
||||
'logdrain_axiom_api_key' => 'secret-axiom-key',
|
||||
'logdrain_newrelic_license_key' => 'secret-nr-key',
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/log-drains")
|
||||
->assertOk()
|
||||
->assertJsonPath('logdrain_axiom_dataset_name', 'coolify-logs')
|
||||
->assertJsonPath('is_logdrain_axiom_enabled', false);
|
||||
|
||||
expect($response->json())->not->toHaveKey('logdrain_axiom_api_key')
|
||||
->and($response->json())->not->toHaveKey('logdrain_newrelic_license_key');
|
||||
});
|
||||
|
||||
test('PATCH updates log drain settings for own team server', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [
|
||||
'logdrain_axiom_dataset_name' => 'api-dataset',
|
||||
'logdrain_axiom_api_key' => 'axiom-key-123',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('logdrain_axiom_dataset_name', 'api-dataset');
|
||||
|
||||
$settings = $this->server->settings->fresh();
|
||||
expect($settings->logdrain_axiom_dataset_name)->toBe('api-dataset')
|
||||
->and($settings->logdrain_axiom_api_key)->toBe('axiom-key-123');
|
||||
});
|
||||
|
||||
test('other-team log drains endpoints return 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains", [
|
||||
'logdrain_axiom_dataset_name' => 'nope',
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sentinel API', function () {
|
||||
test('GET returns sentinel settings without token without read:sensitive', function () {
|
||||
// Avoid fields that trigger restartSentinel() on save (token/url/metrics timing).
|
||||
$this->server->settings->update([
|
||||
'is_sentinel_enabled' => true,
|
||||
'is_metrics_enabled' => true,
|
||||
'is_sentinel_debug_enabled' => false,
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('server-subsystems-read', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Accept' => 'application/json',
|
||||
])
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/sentinel")
|
||||
->assertOk()
|
||||
->assertJsonPath('is_sentinel_enabled', true)
|
||||
->assertJsonPath('is_metrics_enabled', true);
|
||||
|
||||
expect($response->json())->not->toHaveKey('sentinel_token')
|
||||
->and($response->json())->not->toHaveKey('sentinel_custom_url');
|
||||
});
|
||||
|
||||
test('PATCH updates sentinel settings for own team server', function () {
|
||||
// Only toggle fields that do not restart Sentinel (avoids remote StartSentinel on sync queue).
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/sentinel", [
|
||||
'is_metrics_enabled' => true,
|
||||
'is_sentinel_debug_enabled' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('is_metrics_enabled', true)
|
||||
->assertJsonPath('is_sentinel_debug_enabled', true);
|
||||
|
||||
$settings = $this->server->settings->fresh();
|
||||
expect((bool) $settings->is_metrics_enabled)->toBeTrue()
|
||||
->and((bool) $settings->is_sentinel_debug_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('other-team sentinel endpoints return 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/sentinel", [
|
||||
'is_metrics_enabled' => true,
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloudflare Tunnel API', function () {
|
||||
test('GET returns cloudflare tunnel settings for own team server', function () {
|
||||
$this->server->settings->update(['is_cloudflare_tunnel' => true]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel")
|
||||
->assertOk()
|
||||
->assertJsonPath('is_cloudflare_tunnel', true)
|
||||
->assertJsonPath('ip', '10.0.0.10');
|
||||
});
|
||||
|
||||
test('PATCH enables cloudflare tunnel setting for own team server', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel", [
|
||||
'is_cloudflare_tunnel' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('is_cloudflare_tunnel', true);
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST enable and disable match manual UI actions', function () {
|
||||
// Changing ip auto-sets ip_previous to the previous IP via Server model boot.
|
||||
$originalIp = (string) $this->server->ip;
|
||||
$this->server->update(['ip' => '100.64.0.5']);
|
||||
expect((string) $this->server->fresh()->ip_previous)->toBe($originalIp);
|
||||
|
||||
$this->server->settings->update(['is_cloudflare_tunnel' => false]);
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/enable")
|
||||
->assertOk()
|
||||
->assertJsonPath('is_cloudflare_tunnel', true);
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeTrue();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/cloudflare-tunnel/disable")
|
||||
->assertOk()
|
||||
->assertJsonPath('is_cloudflare_tunnel', false)
|
||||
->assertJsonPath('ip', $originalIp);
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalse()
|
||||
->and((string) $this->server->fresh()->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
test('other-team cloudflare tunnel endpoints return 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->getJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel", [
|
||||
'is_cloudflare_tunnel' => true,
|
||||
])
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/enable")
|
||||
->assertNotFound();
|
||||
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->postJson("/api/v1/servers/{$this->otherServer->uuid}/cloudflare-tunnel/disable")
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server update is_terminal_enabled', function () {
|
||||
test('PATCH /servers/{uuid} updates is_terminal_enabled on settings', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->server->uuid}", [
|
||||
'is_terminal_enabled' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('uuid', $this->server->uuid);
|
||||
|
||||
expect((bool) $this->server->settings->fresh()->is_terminal_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
test('PATCH /servers/{uuid} other-team returns 404', function () {
|
||||
$this->withHeaders(serverSubsystemsHeaders())
|
||||
->patchJson("/api/v1/servers/{$this->otherServer->uuid}", [
|
||||
'is_terminal_enabled' => true,
|
||||
])
|
||||
->assertNotFound();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function sharedEnvHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('Team shared environment variables API', function () {
|
||||
test('creates lists updates and deletes team shared envs', function () {
|
||||
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/team/envs', [
|
||||
'key' => 'TEAM_API_KEY',
|
||||
'value' => 'secret-value',
|
||||
'is_literal' => true,
|
||||
'comment' => 'from api',
|
||||
]);
|
||||
|
||||
$create->assertStatus(201);
|
||||
$create->assertJsonStructure(['id']);
|
||||
$envId = $create->json('id');
|
||||
|
||||
expect(SharedEnvironmentVariable::query()->whereKey($envId)->first())
|
||||
->type->toBe('team')
|
||||
->team_id->toBe($this->team->id)
|
||||
->project_id->toBeNull()
|
||||
->key->toBe('TEAM_API_KEY');
|
||||
|
||||
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/team/envs');
|
||||
|
||||
$list->assertOk();
|
||||
$list->assertJsonFragment(['key' => 'TEAM_API_KEY', 'id' => $envId]);
|
||||
|
||||
$update = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/team/envs/{$envId}", [
|
||||
'value' => 'updated-value',
|
||||
'is_multiline' => true,
|
||||
]);
|
||||
|
||||
$update->assertOk();
|
||||
$update->assertJsonFragment(['key' => 'TEAM_API_KEY']);
|
||||
expect($update->json('is_multiline'))->toBeTruthy();
|
||||
|
||||
$delete = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/team/envs/{$envId}");
|
||||
|
||||
$delete->assertOk();
|
||||
$delete->assertJson(['message' => 'Environment variable deleted.']);
|
||||
expect(SharedEnvironmentVariable::query()->whereKey($envId)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('hides value without can_read_sensitive', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'HIDDEN_SECRET',
|
||||
'value' => 'should-not-appear',
|
||||
'type' => 'team',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(sharedEnvHeaders($readToken))
|
||||
->getJson('/api/v1/team/envs');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment(['key' => 'HIDDEN_SECRET']);
|
||||
expect($response->json('0'))->not->toHaveKey('value');
|
||||
});
|
||||
|
||||
test('returns 409 when creating duplicate team key', function () {
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'DUP_KEY',
|
||||
'value' => 'one',
|
||||
'type' => 'team',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/team/envs', [
|
||||
'key' => 'DUP_KEY',
|
||||
'value' => 'two',
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project shared environment variables API', function () {
|
||||
test('creates and lists project shared envs', function () {
|
||||
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/projects/{$this->project->uuid}/envs", [
|
||||
'key' => 'PROJECT_VAR',
|
||||
'value' => 'project-secret',
|
||||
]);
|
||||
|
||||
$create->assertStatus(201);
|
||||
$envId = $create->json('id');
|
||||
|
||||
$env = SharedEnvironmentVariable::query()->whereKey($envId)->first();
|
||||
expect($env)
|
||||
->type->toBe('project')
|
||||
->project_id->toBe($this->project->id)
|
||||
->team_id->toBe($this->team->id);
|
||||
|
||||
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/projects/{$this->project->uuid}/envs");
|
||||
|
||||
$list->assertOk();
|
||||
$list->assertJsonFragment(['key' => 'PROJECT_VAR', 'id' => $envId]);
|
||||
});
|
||||
|
||||
test('returns 404 for project from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
|
||||
SharedEnvironmentVariable::create([
|
||||
'key' => 'OTHER_TEAM_VAR',
|
||||
'value' => 'nope',
|
||||
'type' => 'project',
|
||||
'team_id' => $otherTeam->id,
|
||||
'project_id' => $otherProject->id,
|
||||
]);
|
||||
|
||||
$list = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->getJson("/api/v1/projects/{$otherProject->uuid}/envs");
|
||||
$list->assertStatus(404);
|
||||
|
||||
$create = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->postJson("/api/v1/projects/{$otherProject->uuid}/envs", [
|
||||
'key' => 'SHOULD_FAIL',
|
||||
'value' => 'x',
|
||||
]);
|
||||
$create->assertStatus(404);
|
||||
});
|
||||
|
||||
test('returns 404 when updating env from another team scope', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
|
||||
$otherEnv = SharedEnvironmentVariable::create([
|
||||
'key' => 'FOREIGN',
|
||||
'value' => 'secret',
|
||||
'type' => 'project',
|
||||
'team_id' => $otherTeam->id,
|
||||
'project_id' => $otherProject->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(sharedEnvHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/projects/{$this->project->uuid}/envs/{$otherEnv->id}", [
|
||||
'value' => 'hacked',
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create(['name' => 'Token Team']);
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->bearerToken = $this->user->createToken('team-token-api-test', ['*'])->plainTextToken;
|
||||
});
|
||||
|
||||
function teamTokenApiHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
describe('token team endpoints', function () {
|
||||
test('GET /team returns the token team', function () {
|
||||
$this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/team')
|
||||
->assertOk()
|
||||
->assertJsonPath('id', $this->team->id)
|
||||
->assertJsonPath('name', 'Token Team');
|
||||
});
|
||||
|
||||
test('GET /team/members returns members of the token team', function () {
|
||||
$this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/team/members')
|
||||
->assertOk()
|
||||
->assertJsonFragment(['id' => $this->user->id]);
|
||||
});
|
||||
|
||||
test('deprecated GET /teams/current aliases GET /team', function () {
|
||||
$preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/team')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/teams/current')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
expect($alias)->toBe($preferred);
|
||||
});
|
||||
|
||||
test('deprecated GET /teams/current/members aliases GET /team/members', function () {
|
||||
$preferred = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/team/members')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
$alias = $this->withHeaders(teamTokenApiHeaders($this->bearerToken))
|
||||
->getJson('/api/v1/teams/current/members')
|
||||
->assertOk()
|
||||
->json();
|
||||
|
||||
expect($alias)->toBe($preferred);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,13 @@ use Livewire\Livewire;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'cache.default' => 'array',
|
||||
'session.driver' => 'array',
|
||||
'queue.default' => 'sync',
|
||||
'app.maintenance.driver' => 'file',
|
||||
]);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
@@ -87,6 +94,140 @@ describe('GET /api/v1/tags', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/tags', function () {
|
||||
test('creates a tag for the current team', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/tags', [
|
||||
'name' => 'Production',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment(['name' => 'production'])
|
||||
->assertJsonStructure(['uuid', 'name', 'created_at', 'updated_at']);
|
||||
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'production')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects duplicate tag names for the team', function () {
|
||||
Tag::create(['name' => 'production', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/tags', [
|
||||
'name' => 'production',
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
});
|
||||
|
||||
test('rejects unknown fields', function () {
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/tags', [
|
||||
'name' => 'valid-tag',
|
||||
'unexpected' => 'value',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['unexpected']);
|
||||
});
|
||||
|
||||
test('rejects create requests from non-admin team members', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-token', ['*'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($memberToken))
|
||||
->postJson('/api/v1/tags', [
|
||||
'name' => 'member-tag',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
expect(Tag::where('team_id', $this->team->id)->where('name', 'member-tag')->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/tags/{uuid}', function () {
|
||||
test('renames a team tag', function () {
|
||||
$tag = Tag::create(['name' => 'old-name', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/tags/{$tag->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['uuid' => $tag->uuid, 'name' => 'new name']);
|
||||
|
||||
expect($tag->fresh()->name)->toBe('new name');
|
||||
});
|
||||
|
||||
test('does not rename tags from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$tag = Tag::create(['name' => 'other-tag', 'team_id' => $otherTeam->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/tags/{$tag->uuid}", [
|
||||
'name' => 'stolen',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
expect($tag->fresh()->name)->toBe('other-tag');
|
||||
});
|
||||
|
||||
test('rejects renaming to an existing team tag name', function () {
|
||||
Tag::create(['name' => 'taken', 'team_id' => $this->team->id]);
|
||||
$tag = Tag::create(['name' => 'rename-me', 'team_id' => $this->team->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/tags/{$tag->uuid}", [
|
||||
'name' => 'taken',
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/tags/{uuid}', function () {
|
||||
test('deletes a tag and detaches it from resources', function () {
|
||||
$tag = Tag::create(['name' => 'to-delete', 'team_id' => $this->team->id]);
|
||||
$this->application->tags()->attach($tag->id);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Tag deleted.']);
|
||||
|
||||
expect(Tag::find($tag->id))->toBeNull()
|
||||
->and($this->application->tags()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('does not delete tags from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$tag = Tag::create(['name' => 'other-team-tag', 'team_id' => $otherTeam->id]);
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertNotFound();
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects delete requests from non-admin team members', function () {
|
||||
$tag = Tag::create(['name' => 'protected', 'team_id' => $this->team->id]);
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$memberToken = $member->createToken('member-token', ['*'])->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders(tagApiAuthHeaders($memberToken))
|
||||
->deleteJson("/api/v1/tags/{$tag->uuid}");
|
||||
|
||||
$response->assertForbidden();
|
||||
expect(Tag::find($tag->id))->not->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
@@ -7,6 +7,7 @@ it('defines OpenAPI documentation for every destinations endpoint', function ()
|
||||
expect($model)->toContain("schema: 'Destination'")
|
||||
->and($controller)->toContain("operationId: 'list-destinations'")
|
||||
->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'update-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
|
||||
->and($controller)->toContain("operationId: 'list-server-destinations'")
|
||||
->and($controller)->toContain("operationId: 'create-server-destination'");
|
||||
|
||||
@@ -104,3 +104,56 @@ it('accepts explicitly allowlisted intranet S3 endpoints', function (string $end
|
||||
'Self-hosted MinIO on 172.16.x CIDR' => ['http://172.16.0.10:9000', ['172.16.0.0/12']],
|
||||
'Self-hosted MinIO on 192.168.x exact IP' => ['http://192.168.1.50:9000', ['192.168.1.50']],
|
||||
]);
|
||||
|
||||
it('accepts allowlisted docker-style MinIO hostnames that resolve to private IPs', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected coolify-minio with private IP to pass when allowlisted');
|
||||
});
|
||||
|
||||
it('rejects docker-style MinIO hostnames resolving to private IPs without allowlist', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'webhook_allowed_internal_hosts' => [],
|
||||
]));
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl(fn (string $host): array => ['172.16.0.5'])]],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected coolify-minio private IP rejection without allowlist');
|
||||
});
|
||||
|
||||
it('accepts allowlisted docker MinIO hostname after custom DNS miss and system DNS hit', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['endpoint' => 'http://coolify-minio:9000'],
|
||||
['endpoint' => ['required', 'max:255', $rule]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected S3 endpoint to pass after DNS fallback + allowlist');
|
||||
});
|
||||
@@ -278,3 +278,103 @@ it('redacts webhook URLs for logs', function () {
|
||||
expect(SafeWebhookUrl::redactedUrlForLog('https://hooks.slack.com/services/T000/B000/secret-token?foo=bar'))
|
||||
->toBe('https://hooks.slack.com');
|
||||
});
|
||||
|
||||
it('falls back to system DNS when custom DNS returns no answers', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => ['coolify-minio'],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
/** @var array<int, array{0: string, 1: array<int, string>}> */
|
||||
public array $customCalls = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
public array $systemCalls = [];
|
||||
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
$this->customCalls[] = [$host, $dnsServers];
|
||||
|
||||
// Simulate public DNS (1.1.1.1) having no record for a docker hostname.
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
$this->systemCalls[] = $host;
|
||||
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'http://coolify-minio:9000'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue('Expected system-DNS fallback for allowlisted docker hostname')
|
||||
->and($rule->customCalls)->toHaveCount(1)
|
||||
->and($rule->customCalls[0][0])->toBe('coolify-minio')
|
||||
->and($rule->customCalls[0][1])->toBe(['1.1.1.1'])
|
||||
->and($rule->systemCalls)->toBe(['coolify-minio']);
|
||||
});
|
||||
|
||||
it('does not fall back to system DNS when custom DNS returns answers', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
public int $systemCalls = 0;
|
||||
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return ['93.184.216.34'];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
$this->systemCalls++;
|
||||
|
||||
return ['10.0.0.1'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'https://example.com/webhook'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue()
|
||||
->and($rule->systemCalls)->toBe(0);
|
||||
});
|
||||
|
||||
it('still rejects private targets after system DNS fallback when host is not allowlisted', function () {
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], [
|
||||
'custom_dns_servers' => '1.1.1.1',
|
||||
'webhook_allowed_internal_hosts' => [],
|
||||
]));
|
||||
|
||||
$rule = new class extends SafeWebhookUrl
|
||||
{
|
||||
protected function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function resolveHostWithSystemDns(string $host): array
|
||||
{
|
||||
return ['172.16.0.5'];
|
||||
}
|
||||
};
|
||||
|
||||
$validator = Validator::make(
|
||||
['url' => 'http://coolify-minio:9000'],
|
||||
['url' => $rule],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue('Expected private IP rejection without allowlist after fallback');
|
||||
});
|
||||
Reference in new issue
Block a user