From 63008fceb3d74643e8508e0856394804e06ce961 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:15:00 +0100 Subject: [PATCH 1/8] feat(api): add ownedByCurrentTeamAPI scope to Environment model --- app/Models/Environment.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/Environment.php b/app/Models/Environment.php index d4e614e6e..ae027a585 100644 --- a/app/Models/Environment.php +++ b/app/Models/Environment.php @@ -42,6 +42,11 @@ public static function ownedByCurrentTeam() return Environment::whereRelation('project.team', 'id', currentTeam()->id)->orderBy('name'); } + public static function ownedByCurrentTeamAPI(int $teamId) + { + return Environment::whereRelation('project.team', 'id', $teamId)->orderBy('name'); + } + public function isEmpty() { return $this->applications()->count() == 0 && From d8178df838f041c8ebe3a920ed6a2d71e1d6f879 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 15:48:00 +0100 Subject: [PATCH 2/8] feat(api): add shared helper for moving resources between environments --- bootstrap/helpers/api.php | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 43c074cd1..b0addaf0e 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -144,6 +144,50 @@ function sharedDataApplications() ]; } +function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): \Illuminate\Http\JsonResponse +{ + + $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ + 'environment_uuid' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $extraFields = array_diff(array_keys($request->all()), ['environment_uuid']); + if (! empty($extraFields)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(), + ], 422); + } + + $newEnvironment = \App\Models\Environment::ownedByCurrentTeamAPI($teamId) + ->whereUuid($request->environment_uuid) + ->first(); + + if (! $newEnvironment) { + return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404); + } + + if ($resource->environment_id === $newEnvironment->id) { + return response()->json(['message' => "$resourceType is already in this environment."], 400); + } + + $resource->update(['environment_id' => $newEnvironment->id]); + + return response()->json([ + 'message' => "$resourceType moved successfully.", + 'uuid' => $resource->uuid, + 'project_uuid' => $newEnvironment->project->uuid, + 'environment_uuid' => $newEnvironment->uuid, + ]); +} + function validateIncomingRequest(Request $request) { // check if request is json From 6b9a755e32db7e0022983d40d1967ac7f325ad91 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 16:32:00 +0100 Subject: [PATCH 3/8] feat(api): add POST /move endpoints for applications, databases, and services --- .../Api/ApplicationsController.php | 93 +++++++++++++++++++ .../Controllers/Api/DatabasesController.php | 93 +++++++++++++++++++ .../Controllers/Api/ServicesController.php | 93 +++++++++++++++++++ routes/api.php | 3 + 4 files changed, 282 insertions(+) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 4b0cfc6ab..492dd8c93 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3828,6 +3828,99 @@ public function action_restart(Request $request) ); } + #[OA\Post( + summary: 'Move', + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.', + path: '/applications/{uuid}/move', + operationId: 'move-application-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the application to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Application moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Application moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + + $this->authorize('update', $application); + + return moveResourceToEnvironment($request, $application, 'Application', $teamId); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index f7a62cf90..c37edf6cd 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2503,6 +2503,99 @@ public function list_backup_executions(Request $request) ]); } + #[OA\Post( + summary: 'Move', + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.', + path: '/databases/{uuid}/move', + operationId: 'move-database-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the database to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Database moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Database moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $this->authorize('update', $database); + + return moveResourceToEnvironment($request, $database, 'Database', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start database. `Post` request is also accepted.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 32097443e..a4a74463a 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1591,6 +1591,99 @@ public function delete_env_by_uuid(Request $request) return response()->json(['message' => 'Environment variable deleted.']); } + #[OA\Post( + summary: 'Move', + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.', + path: '/services/{uuid}/move', + operationId: 'move-service-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + ) + ), + ], + requestBody: new OA\RequestBody( + description: 'Target environment to move the service to.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'], + ], + required: ['environment_uuid'], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 200, + description: 'Service moved successfully.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Service moved successfully.'], + 'uuid' => ['type' => 'string'], + 'project_uuid' => ['type' => 'string'], + 'environment_uuid' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function move_by_uuid(Request $request): \Illuminate\Http\JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('update', $service); + + return moveResourceToEnvironment($request, $service, 'Service', $teamId); + } + #[OA\Get( summary: 'Start', description: 'Start service. `Post` request is also accepted.', diff --git a/routes/api.php b/routes/api.php index 8b28177f3..a3dfc02d5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -121,6 +121,7 @@ Route::delete('/applications/{uuid}/envs/{env_uuid}', [ApplicationsController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::get('/applications/{uuid}/logs', [ApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/applications/{uuid}/start', [ApplicationsController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/restart', [ApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/applications/{uuid}/stop', [ApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -152,6 +153,7 @@ Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); + Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/databases/{uuid}/stop', [DatabasesController::class, 'action_stop'])->middleware(['api.ability:deploy']); @@ -169,6 +171,7 @@ Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); From 94700347f80f5280a1e6f52ebb58543ba9ec1473 Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 17:05:00 +0100 Subject: [PATCH 4/8] test: add move resource API tests --- tests/Feature/MoveResourceApiTest.php | 238 ++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/Feature/MoveResourceApiTest.php diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php new file mode 100644 index 000000000..80d50122b --- /dev/null +++ b/tests/Feature/MoveResourceApiTest.php @@ -0,0 +1,238 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->targetProject = Project::factory()->create(['team_id' => $this->team->id]); + $this->targetEnvironment = Environment::factory()->create(['project_id' => $this->targetProject->id]); +}); + +describe('POST /api/v1/applications/{uuid}/move', function () { + test('moves application to another environment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Application moved successfully.']); + $response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']); + + $application->refresh(); + expect($application->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when application not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/applications/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); + + test('returns 422 when environment_uuid is missing', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", []); + + $response->assertStatus(422); + }); + + test('returns 422 when extra fields are provided', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + 'bogus_field' => 'value', + ]); + + $response->assertStatus(422); + }); + + test('returns 404 when target environment belongs to another team', function () { + $otherTeam = Team::factory()->create(); + $otherProject = Project::factory()->create(['team_id' => $otherTeam->id]); + $otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]); + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $otherEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); + + test('returns 400 when application is already in the target environment', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->environment->uuid, + ]); + + $response->assertStatus(400); + }); + + test('preserves resource-level environment variables after move', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + \App\Models\EnvironmentVariable::create([ + 'key' => 'TEST_VAR', + 'value' => 'test-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + + $application->refresh(); + $envVar = $application->environment_variables->where('key', 'TEST_VAR')->first(); + expect($envVar)->not->toBeNull(); + expect($envVar->value)->toBe('test-value'); + }); +}); + +describe('POST /api/v1/databases/{uuid}/move', function () { + test('moves database to another environment', function () { + $database = StandalonePostgresql::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/databases/{$database->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Database moved successfully.']); + + $database->refresh(); + expect($database->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when database not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/databases/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); +}); + +describe('POST /api/v1/services/{uuid}/move', function () { + test('moves service to another environment', function () { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/services/{$service->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service moved successfully.']); + + $service->refresh(); + expect($service->environment_id)->toBe($this->targetEnvironment->id); + }); + + test('returns 404 when service not found', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/services/non-existent-uuid/move', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertStatus(404); + }); +}); From 2f8df2f9bd86f5d0337e28a93a412fe40f2016cb Mon Sep 17 00:00:00 2001 From: Niklas Wichter Date: Fri, 13 Mar 2026 17:25:00 +0100 Subject: [PATCH 5/8] fix(test): align test setup with project conventions --- tests/Feature/MoveResourceApiTest.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index 80d50122b..faf4f699b 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; use App\Models\Service; @@ -14,6 +15,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -24,12 +27,12 @@ $this->bearerToken = $this->token->plainTextToken; $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create(['server_id' => $this->server->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->environment = $this->project->environments()->first(); $this->targetProject = Project::factory()->create(['team_id' => $this->team->id]); - $this->targetEnvironment = Environment::factory()->create(['project_id' => $this->targetProject->id]); + $this->targetEnvironment = $this->targetProject->environments()->first(); }); describe('POST /api/v1/applications/{uuid}/move', function () { @@ -170,7 +173,11 @@ describe('POST /api/v1/databases/{uuid}/move', function () { test('moves database to another environment', function () { - $database = StandalonePostgresql::factory()->create([ + $database = StandalonePostgresql::create([ + 'name' => 'test-pg', + 'postgres_user' => 'postgres', + 'postgres_password' => 'secret', + 'postgres_db' => 'testdb', 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), From 676161a627aab9291837e573b701701db4d141b3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:56:55 +0200 Subject: [PATCH 6/8] fix(api): authorize target environment moves --- bootstrap/helpers/api.php | 3 +++ tests/Feature/MoveResourceApiTest.php | 31 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 54f275991..dccfec4af 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\Rule; function getTeamIdFromToken() @@ -202,6 +203,8 @@ function moveResourceToEnvironment(Request $request, $resource, string $resource return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404); } + Gate::authorize('update', $newEnvironment); + if ($resource->environment_id === $newEnvironment->id) { return response()->json(['message' => "$resourceType is already in this environment."], 400); } diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index faf4f699b..82ee221b1 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -11,6 +11,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Gate; uses(RefreshDatabase::class); @@ -123,6 +124,36 @@ $response->assertStatus(404); }); + test('returns 403 when target environment update is denied', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Gate::before(function (User $user, string $ability, array $arguments) { + $target = $arguments[0] ?? null; + + if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) { + return false; + } + + return null; + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response->assertForbidden(); + + $application->refresh(); + expect($application->environment_id)->toBe($this->environment->id); + }); + test('returns 400 when application is already in the target environment', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, From f244b5e25d1f91968333d6877b80184fd9d68e34 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:07:50 +0200 Subject: [PATCH 7/8] test(api): assert target env authorization on moves --- openapi.json | 255 ++++++++++++++++++++++++++ openapi.yaml | 153 ++++++++++++++++ tests/Feature/MoveResourceApiTest.php | 17 +- 3 files changed, 418 insertions(+), 7 deletions(-) diff --git a/openapi.json b/openapi.json index d45d0fb49..6e49729ce 100644 --- a/openapi.json +++ b/openapi.json @@ -3364,6 +3364,91 @@ ] } }, + "\/applications\/{uuid}\/move": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Move", + "description": "Move application to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-application-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the application.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the application to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Application moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Application moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/{uuid}\/storages": { "get": { "tags": [ @@ -6494,6 +6579,91 @@ ] } }, + "\/databases\/{uuid}\/move": { + "post": { + "tags": [ + "Databases" + ], + "summary": "Move", + "description": "Move database to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-database-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the database.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the database to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Database moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Database moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/databases\/{uuid}\/start": { "get": { "tags": [ @@ -12982,6 +13152,91 @@ ] } }, + "\/services\/{uuid}\/move": { + "post": { + "tags": [ + "Services" + ], + "summary": "Move", + "description": "Move service to another project\/environment. This is a purely organizational change \u2014 running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.", + "operationId": "move-service-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "UUID of the service.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Target environment to move the service to.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "environment_uuid" + ], + "properties": { + "environment_uuid": { + "type": "string", + "description": "UUID of the target environment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Service moved successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Service moved successfully." + }, + "uuid": { + "type": "string" + }, + "project_uuid": { + "type": "string" + }, + "environment_uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/services\/{uuid}\/start": { "get": { "tags": [ diff --git a/openapi.yaml b/openapi.yaml index 9b7288eba..dd97873a2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2141,6 +2141,57 @@ paths: security: - bearerAuth: [] + '/applications/{uuid}/move': + post: + tags: + - Applications + summary: Move + description: 'Move application to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the application will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-application-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the application.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the application to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Application moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Application moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/applications/{uuid}/storages': get: tags: @@ -4248,6 +4299,57 @@ paths: security: - bearerAuth: [] + '/databases/{uuid}/move': + post: + tags: + - Databases + summary: Move + description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-database-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the database.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the database to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Database moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Database moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/databases/{uuid}/start': get: tags: @@ -8232,6 +8334,57 @@ paths: security: - bearerAuth: [] + '/services/{uuid}/move': + post: + tags: + - Services + summary: Move + description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.' + operationId: move-service-by-uuid + parameters: + - + name: uuid + in: path + description: 'UUID of the service.' + required: true + schema: + type: string + requestBody: + description: 'Target environment to move the service to.' + required: true + content: + application/json: + schema: + required: + - environment_uuid + properties: + environment_uuid: + type: string + description: 'UUID of the target environment.' + type: object + responses: + '200': + description: 'Service moved successfully.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Service moved successfully.' } + uuid: { type: string } + project_uuid: { type: string } + environment_uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] '/services/{uuid}/start': get: tags: diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index 82ee221b1..bee517a54 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -2,6 +2,7 @@ use App\Models\Application; use App\Models\Environment; +use App\Models\EnvironmentVariable; use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; @@ -10,7 +11,9 @@ use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; uses(RefreshDatabase::class); @@ -124,7 +127,9 @@ $response->assertStatus(404); }); - test('returns 403 when target environment update is denied', function () { + test('authorizes the target environment before moving', function () { + $this->actingAs($this->user); + $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, @@ -141,14 +146,12 @@ return null; }); - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson("/api/v1/applications/{$application->uuid}/move", [ + $request = Request::create('/', 'POST', [ 'environment_uuid' => $this->targetEnvironment->uuid, ]); - $response->assertForbidden(); + expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id)) + ->toThrow(AuthorizationException::class); $application->refresh(); expect($application->environment_id)->toBe($this->environment->id); @@ -178,7 +181,7 @@ 'destination_type' => $this->destination->getMorphClass(), ]); - \App\Models\EnvironmentVariable::create([ + EnvironmentVariable::create([ 'key' => 'TEST_VAR', 'value' => 'test-value', 'resourceable_type' => Application::class, From 6872f63f5f43192a7d9fe6d68730130e05ad4595 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:20:41 +0200 Subject: [PATCH 8/8] fix(api): audit moved resources --- bootstrap/helpers/api.php | 21 +++++++++++++--- tests/Feature/MoveResourceApiTest.php | 35 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index dccfec4af..e430e7364 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -3,12 +3,15 @@ use App\Enums\BuildPackTypes; use App\Enums\RedirectTypes; use App\Enums\StaticImageTypes; +use App\Models\Environment; use App\Rules\ValidGitBranch; use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; function getTeamIdFromToken() @@ -173,10 +176,10 @@ function sharedDataApplications() ]; } -function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): \Illuminate\Http\JsonResponse +function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse { - $validator = \Illuminate\Support\Facades\Validator::make($request->all(), [ + $validator = Validator::make($request->all(), [ 'environment_uuid' => 'required|string', ]); @@ -195,7 +198,7 @@ function moveResourceToEnvironment(Request $request, $resource, string $resource ], 422); } - $newEnvironment = \App\Models\Environment::ownedByCurrentTeamAPI($teamId) + $newEnvironment = Environment::ownedByCurrentTeamAPI($teamId) ->whereUuid($request->environment_uuid) ->first(); @@ -209,8 +212,20 @@ function moveResourceToEnvironment(Request $request, $resource, string $resource return response()->json(['message' => "$resourceType is already in this environment."], 400); } + $oldEnvironment = $resource->environment()->with('project')->first(); + $resource->update(['environment_id' => $newEnvironment->id]); + auditLog('api.'.str($resourceType)->lower()->value().'.moved', [ + 'team_id' => $teamId, + 'resource_uuid' => $resource->uuid, + 'resource_type' => str($resourceType)->lower()->value(), + 'from_project_uuid' => $oldEnvironment?->project?->uuid, + 'from_environment_uuid' => $oldEnvironment?->uuid, + 'to_project_uuid' => $newEnvironment->project->uuid, + 'to_environment_uuid' => $newEnvironment->uuid, + ]); + return response()->json([ 'message' => "$resourceType moved successfully.", 'uuid' => $resource->uuid, diff --git a/tests/Feature/MoveResourceApiTest.php b/tests/Feature/MoveResourceApiTest.php index bee517a54..29c868216 100644 --- a/tests/Feature/MoveResourceApiTest.php +++ b/tests/Feature/MoveResourceApiTest.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Log; uses(RefreshDatabase::class); @@ -62,6 +63,40 @@ expect($application->environment_id)->toBe($this->targetEnvironment->id); }); + test('writes audit log when application is moved', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('api.application.moved', Mockery::on(function (array $context) use ($application) { + return $context['event'] === 'api.application.moved' + && $context['resource_uuid'] === $application->uuid + && $context['resource_type'] === 'application' + && $context['from_environment_uuid'] === $this->environment->uuid + && $context['to_environment_uuid'] === $this->targetEnvironment->uuid + && $context['from_project_uuid'] === $this->project->uuid + && $context['to_project_uuid'] === $this->targetProject->uuid; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + Log::shouldReceive('warning')->andReturnNull(); + + $this->actingAs($this->user); + + $request = Request::create('/', 'POST', [ + 'environment_uuid' => $this->targetEnvironment->uuid, + ]); + + $response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id); + + expect($response->getStatusCode())->toBe(200); + }); + test('returns 404 when application not found', function () { $response = $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken,