Merge remote-tracking branch 'origin/next' into volume-backups-server-s3

This commit is contained in:
Andras Bacsai committed 2026-07-15 16:33:51 +02:00
commit 995ec5fbb6
17 files changed
+2125 -47

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->bearerToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
'force_disabled' => false,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createBearerTokenForServiceDatabasesApiTest(User $user, Team $team, array $abilities = ['*']): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function createServiceWithDatabaseForApiTest(object $context): object
{
$service = Service::factory()->create([
'environment_id' => $context->environment->id,
'server_id' => $context->server->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
'docker_compose_raw' => "services:\n postgres:\n image: postgres:17-alpine\n",
]);
$serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'postgres',
'service_id' => $service->id,
'image' => 'postgres:17-alpine',
'is_public' => false,
]);
return (object) ['service' => $service, 'serviceDatabase' => $serviceDatabase];
}
test('lists and gets databases belonging to a service', function () {
$context = createServiceWithDatabaseForApiTest($this);
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)
->getJson("/api/v1/services/{$context->service->uuid}/databases")
->assertOk()
->assertJsonFragment(['uuid' => $context->serviceDatabase->uuid]);
$this->withHeaders($headers)
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}")
->assertOk()
->assertJsonFragment(['name' => 'postgres']);
});
test('does not return a database from another service', function () {
$context = createServiceWithDatabaseForApiTest($this);
$otherContext = createServiceWithDatabaseForApiTest($this);
$this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken])
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$otherContext->serviceDatabase->uuid}")
->assertNotFound()
->assertJsonFragment(['message' => 'Service database not found.']);
});
test('updates supported service database fields and rejects unknown fields', function () {
$context = createServiceWithDatabaseForApiTest($this);
$url = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}";
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)->patchJson($url, [
'human_name' => 'Primary database',
'description' => 'Stores production data',
'public_port' => 5432,
'public_port_timeout' => 1800,
'exclude_from_status' => true,
])->assertOk()->assertJsonFragment([
'human_name' => 'Primary database',
'public_port' => 5432,
]);
$context->serviceDatabase->refresh();
expect($context->serviceDatabase->human_name)->toBe('Primary database')
->and($context->serviceDatabase->public_port_timeout)->toBe(1800)
->and($context->serviceDatabase->exclude_from_status)->toBeTrue();
$this->withHeaders($headers)
->patchJson($url, ['unsupported' => true])
->assertUnprocessable()
->assertJsonPath('errors.unsupported.0', 'This field is not allowed.');
});
test('queues lifecycle actions for a service database', function () {
$context = createServiceWithDatabaseForApiTest($this);
$baseUrl = "/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}";
$headers = ['Authorization' => 'Bearer '.$this->bearerToken];
$this->withHeaders($headers)->postJson("{$baseUrl}/start?latest=1&force=1")
->assertOk()
->assertJsonFragment(['message' => 'Service database deploy request queued.']);
DeployServiceApplication::assertPushed();
$this->withHeaders($headers)->postJson("{$baseUrl}/restart")
->assertOk()
->assertJsonFragment(['message' => 'Service database restart request queued.']);
RestartServiceApplication::assertPushed();
$this->withHeaders($headers)->postJson("{$baseUrl}/stop")
->assertOk()
->assertJsonFragment(['message' => 'Service database stop request queued.']);
StopServiceApplication::assertPushed();
});
test('requires deploy ability for service database lifecycle actions', function () {
$context = createServiceWithDatabaseForApiTest($this);
$writeToken = createBearerTokenForServiceDatabasesApiTest($this->user, $this->team, ['write']);
$this->withHeaders(['Authorization' => 'Bearer '.$writeToken])
->postJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/restart")
->assertForbidden();
});
test('returns a clear error when logs cannot be read from the server', function () {
$context = createServiceWithDatabaseForApiTest($this);
$this->server->settings->update(['is_reachable' => false]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken])
->getJson("/api/v1/services/{$context->service->uuid}/databases/{$context->serviceDatabase->uuid}/logs")
->assertBadRequest()
->assertJsonFragment(['message' => 'Server is not functional.']);
});
+27
View File
@@ -0,0 +1,27 @@
<?php
use App\Models\Application;
use OpenApi\Attributes\Property;
use OpenApi\Attributes\Schema;
it('documents application settings under the settings property', function () {
$schema = (new ReflectionClass(Application::class))
->getAttributes(Schema::class)[0]
->newInstance();
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$settingsProperty = collect($schema->properties)
->first(fn (mixed $property): bool => $property instanceof Property
&& $property->property === 'settings');
expect($settingsProperty)
->not->toBeNull()
->and($openApi['components']['schemas']['Application']['properties'])
->toHaveKey('settings')
->not->toHaveKey('');
});
@@ -0,0 +1,32 @@
<?php
it('documents both supported methods for service application actions', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$actionPaths = [
'/services/{uuid}/applications/{app_uuid}/logs',
'/services/{uuid}/applications/{app_uuid}/start',
'/services/{uuid}/applications/{app_uuid}/restart',
'/services/{uuid}/applications/{app_uuid}/stop',
];
foreach ($actionPaths as $path) {
expect($openApi['paths'][$path])
->toHaveKeys(['get', 'post'])
->and($openApi['paths'][$path]['post']['responses'])
->toHaveKeys(['200', '400', '401', '404', '501']);
}
expect($openApi['paths'][$actionPaths[0]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('logs')
->and($openApi['paths'][$actionPaths[1]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[2]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message')
->and($openApi['paths'][$actionPaths[3]]['post']['responses']['200']['content']['application/json']['schema']['properties'])
->toHaveKey('message');
});
@@ -0,0 +1,29 @@
<?php
it('documents service database management endpoints', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
expect($openApi['paths'])
->toHaveKey('/services/{uuid}/databases')
->toHaveKey('/services/{uuid}/databases/{database_uuid}')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/logs')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/start')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/restart')
->toHaveKey('/services/{uuid}/databases/{database_uuid}/stop')
->and($openApi['paths']['/services/{uuid}/databases']['get'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}'])
->toHaveKeys(['get', 'patch'])
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/logs']['get'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/start']['post'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/restart']['post'])
->toBeArray()
->and($openApi['paths']['/services/{uuid}/databases/{database_uuid}/stop']['post'])
->toBeArray();
});
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Http\Controllers\Api\ServicesController;
use OpenApi\Attributes\Patch;
it('only documents fields supported by the update service endpoint', function () {
$method = new ReflectionMethod(ServicesController::class, 'update_by_uuid');
$patch = $method->getAttributes(Patch::class)[0]->newInstance();
$documentedProperties = $patch->requestBody->content[0]->schema->properties;
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$generatedProperties = $openApi['paths']['/services/{uuid}']['patch']['requestBody']['content']['application/json']['schema']['properties'];
$unsupportedFields = [
'project_uuid',
'environment_name',
'environment_uuid',
'server_uuid',
'destination_uuid',
];
expect(array_keys($generatedProperties))->toBe(array_keys($documentedProperties))
->and(array_intersect($unsupportedFields, array_keys($documentedProperties)))->toBeEmpty();
});