feat(api): add POST /move endpoints to relocate resources between environments (#8968)

This commit is contained in:
Andras Bacsai 2026-07-07 21:21:11 +02:00 committed by GitHub
commit b93f1090ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1071 additions and 0 deletions

View file

@ -3950,6 +3950,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();

View file

@ -2943,6 +2943,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.',

View file

@ -1869,6 +1869,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.',

View file

@ -47,6 +47,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 &&

View file

@ -3,11 +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()
@ -172,6 +176,64 @@ function sharedDataApplications()
];
}
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
$validator = 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 = 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);
}
Gate::authorize('update', $newEnvironment);
if ($resource->environment_id === $newEnvironment->id) {
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,
'project_uuid' => $newEnvironment->project->uuid,
'environment_uuid' => $newEnvironment->uuid,
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json

View file

@ -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": [

View file

@ -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:

View file

@ -140,6 +140,7 @@
Route::post('/applications/{uuid}/tags', [ApplicationsController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
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']);
@ -189,6 +190,7 @@
Route::post('/databases/{uuid}/tags', [DatabasesController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->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']);
@ -216,6 +218,7 @@
Route::post('/services/{uuid}/tags', [ServicesController::class, 'create_tag'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->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']);

View file

@ -0,0 +1,314 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $this->project->environments()->first();
$this->targetProject = Project::factory()->create(['team_id' => $this->team->id]);
$this->targetEnvironment = $this->targetProject->environments()->first();
});
describe('POST /api/v1/applications/{uuid}/move', function () {
test('moves application to another environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Application moved successfully.']);
$response->assertJsonStructure(['message', 'uuid', 'project_uuid', 'environment_uuid']);
$application->refresh();
expect($application->environment_id)->toBe($this->targetEnvironment->id);
});
test('writes audit log when application is moved', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.application.moved', Mockery::on(function (array $context) use ($application) {
return $context['event'] === 'api.application.moved'
&& $context['resource_uuid'] === $application->uuid
&& $context['resource_type'] === 'application'
&& $context['from_environment_uuid'] === $this->environment->uuid
&& $context['to_environment_uuid'] === $this->targetEnvironment->uuid
&& $context['from_project_uuid'] === $this->project->uuid
&& $context['to_project_uuid'] === $this->targetProject->uuid;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Log::shouldReceive('warning')->andReturnNull();
$this->actingAs($this->user);
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response = moveResourceToEnvironment($request, $application, 'Application', $this->team->id);
expect($response->getStatusCode())->toBe(200);
});
test('returns 404 when application not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/applications/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('returns 422 when environment_uuid is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", []);
$response->assertStatus(422);
});
test('returns 422 when extra fields are provided', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
'bogus_field' => 'value',
]);
$response->assertStatus(422);
});
test('returns 404 when target environment belongs to another team', function () {
$otherTeam = Team::factory()->create();
$otherProject = Project::factory()->create(['team_id' => $otherTeam->id]);
$otherEnvironment = Environment::factory()->create(['project_id' => $otherProject->id]);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $otherEnvironment->uuid,
]);
$response->assertStatus(404);
});
test('authorizes the target environment before moving', function () {
$this->actingAs($this->user);
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Gate::before(function (User $user, string $ability, array $arguments) {
$target = $arguments[0] ?? null;
if ($ability === 'update' && $target instanceof Environment && $target->is($this->targetEnvironment)) {
return false;
}
return null;
});
$request = Request::create('/', 'POST', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
expect(fn () => moveResourceToEnvironment($request, $application, 'Application', $this->team->id))
->toThrow(AuthorizationException::class);
$application->refresh();
expect($application->environment_id)->toBe($this->environment->id);
});
test('returns 400 when application is already in the target environment', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->environment->uuid,
]);
$response->assertStatus(400);
});
test('preserves resource-level environment variables after move', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_VAR',
'value' => 'test-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$application->refresh();
$envVar = $application->environment_variables->where('key', 'TEST_VAR')->first();
expect($envVar)->not->toBeNull();
expect($envVar->value)->toBe('test-value');
});
});
describe('POST /api/v1/databases/{uuid}/move', function () {
test('moves database to another environment', function () {
$database = StandalonePostgresql::create([
'name' => 'test-pg',
'postgres_user' => 'postgres',
'postgres_password' => 'secret',
'postgres_db' => 'testdb',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Database moved successfully.']);
$database->refresh();
expect($database->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when database not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/databases/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});
describe('POST /api/v1/services/{uuid}/move', function () {
test('moves service to another environment', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/services/{$service->uuid}/move", [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(200);
$response->assertJsonFragment(['message' => 'Service moved successfully.']);
$service->refresh();
expect($service->environment_id)->toBe($this->targetEnvironment->id);
});
test('returns 404 when service not found', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/services/non-existent-uuid/move', [
'environment_uuid' => $this->targetEnvironment->uuid,
]);
$response->assertStatus(404);
});
});