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

This commit is contained in:
Andras Bacsai committed 2026-07-19 21:45:26 +02:00
commit b97a97e955
38 files changed
+379 -651

No files matched your search

@@ -0,0 +1,56 @@
<?php
use App\Actions\Server\ValidateServer;
use App\Jobs\ValidateAndInstallServerJob;
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::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken;
Queue::fake();
});
function serverValidationHeaders(): array
{
return ['Authorization' => 'Bearer '.test()->token];
}
it('validates without installing by default', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate");
$response->assertCreated()->assertJson(['message' => 'Validation started.']);
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
});
it('validates and installs when explicitly requested', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => true]);
$response->assertCreated()->assertJson(['message' => 'Validation and installation started.']);
Queue::assertPushed(
ValidateAndInstallServerJob::class,
fn (ValidateAndInstallServerJob $job): bool => $job->server->is($this->server)
);
Queue::assertNotPushed(ValidateServer::class);
});
it('rejects an invalid install option', function () {
$response = $this->withHeaders(serverValidationHeaders())
->postJson("/api/v1/servers/{$this->server->uuid}/validate", ['install' => 'yes']);
$response->assertUnprocessable()->assertJsonValidationErrors('install');
});
@@ -0,0 +1,43 @@
<?php
use App\Http\Controllers\Api\OtherController;
use Illuminate\Routing\Route;
it('uses POST for state changes and keeps GET as a non-mutating compatibility response', function (string $uri) {
$routes = collect(app('router')->getRoutes()->getRoutes())
->filter(fn (Route $route): bool => $route->uri() === $uri);
$postRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['POST']);
$getRoute = $routes->first(fn (Route $route): bool => $route->methods() === ['GET', 'HEAD']);
expect($postRoute)->not->toBeNull()
->and($getRoute)->not->toBeNull()
->and($getRoute->getActionName())->toBe(OtherController::class.'@post_required');
})->with([
'enable API' => 'api/v1/enable',
'disable API' => 'api/v1/disable',
'deploy' => 'api/v1/deploy',
'validate server' => 'api/v1/servers/{uuid}/validate',
'start application' => 'api/v1/applications/{uuid}/start',
'restart application' => 'api/v1/applications/{uuid}/restart',
'stop application' => 'api/v1/applications/{uuid}/stop',
'start database' => 'api/v1/databases/{uuid}/start',
'restart database' => 'api/v1/databases/{uuid}/restart',
'stop database' => 'api/v1/databases/{uuid}/stop',
'start service' => 'api/v1/services/{uuid}/start',
'restart service' => 'api/v1/services/{uuid}/restart',
'stop service' => 'api/v1/services/{uuid}/stop',
'start service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/start',
'restart service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/restart',
'stop service application' => 'api/v1/services/{uuid}/applications/{app_uuid}/stop',
]);
it('tells GET callers to use POST without changing state', function () {
$response = app(OtherController::class)->post_required();
expect($response->getStatusCode())->toBe(405)
->and($response->headers->get('Allow'))->toBe('POST')
->and($response->getData(true))->toBe([
'message' => 'This endpoint has changed to a POST request.',
]);
});
@@ -77,13 +77,13 @@ describe('POST /api/v1/servers', function () {
});
});
describe('GET /api/v1/servers/{uuid}/validate', function () {
describe('POST /api/v1/servers/{uuid}/validate', function () {
test('read-only token cannot trigger server validation', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/servers/fake-uuid/validate');
])->postJson('/api/v1/servers/fake-uuid/validate');
$response->assertStatus(403);
});
+1 -1
View File
@@ -397,7 +397,7 @@ describe('API mutation audit logging', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/enable');
])->postJson('/api/v1/enable');
$response->assertStatus(403);
});
@@ -0,0 +1,19 @@
<?php
it('documents optional installation during server validation', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
flags: JSON_THROW_ON_ERROR,
);
$operation = $openApi['paths']['/servers/{uuid}/validate']['post'];
$install = $operation['requestBody']['content']['application/json']['schema']['properties']['install'];
expect($install)
->toMatchArray([
'type' => 'boolean',
'default' => false,
])
->and($install['description'])->toContain('restart the Docker daemon');
});
@@ -1,6 +1,6 @@
<?php
it('documents both supported methods for service application actions', function () {
it('documents POST for service application actions', function () {
$openApi = json_decode(
file_get_contents(__DIR__.'/../../openapi.json'),
true,
@@ -8,7 +8,6 @@ it('documents both supported methods for service application actions', function
);
$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',
@@ -16,17 +15,16 @@ it('documents both supported methods for service application actions', function
foreach ($actionPaths as $path) {
expect($openApi['paths'][$path])
->toHaveKeys(['get', 'post'])
->toHaveKey('post')
->not->toHaveKey('get')
->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')
->toHaveKey('message')
->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');
});