diff --git a/app/Domains/Integration/Controllers/ClientIntegrationController.php b/app/Domains/Integration/Controllers/ClientIntegrationController.php index 9b243cf..01ffd41 100644 --- a/app/Domains/Integration/Controllers/ClientIntegrationController.php +++ b/app/Domains/Integration/Controllers/ClientIntegrationController.php @@ -4,8 +4,12 @@ namespace App\Domains\Integration\Controllers; use App\Domains\Client\Models\Client; use App\Domains\Integration\Models\Integration; +use App\Domains\Integration\Models\IntegrationInstance; +use App\Domains\Integration\Requests\AssociateIntegrationInstanceRequest; use App\Domains\Integration\Requests\StoreClientIntegrationRequest; +use App\Domains\Integration\Resources\IntegrationAssociationResource; use App\Domains\Integration\Services\ClientIntegrationService; +use App\Domains\Integration\Services\IntegrationAssociationService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -61,4 +65,18 @@ class ClientIntegrationController extends Controller ], 400); } } + + public function associate(AssociateIntegrationInstanceRequest $request, Client $client, string $integrationCode, IntegrationAssociationService $service) + { + return new IntegrationAssociationResource($service->associate( + $client, $integrationCode, IntegrationInstance::findOrFail($request->validated('integration_instance_id')), + )); + } + + public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service) + { + $service->detach($client, $integrationCode); + + return response()->noContent(); + } } diff --git a/app/Domains/Integration/Controllers/IntegrationController.php b/app/Domains/Integration/Controllers/IntegrationController.php index 907d96d..2d7de19 100644 --- a/app/Domains/Integration/Controllers/IntegrationController.php +++ b/app/Domains/Integration/Controllers/IntegrationController.php @@ -35,6 +35,7 @@ class IntegrationController extends Controller public function destroy(Integration $integration) { + abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.'); $integration->delete(); return response()->noContent(); diff --git a/app/Domains/Integration/Controllers/IntegrationInstanceController.php b/app/Domains/Integration/Controllers/IntegrationInstanceController.php new file mode 100644 index 0000000..4d0781a --- /dev/null +++ b/app/Domains/Integration/Controllers/IntegrationInstanceController.php @@ -0,0 +1,42 @@ +orderBy('id')->paginate()); + } + + public function store(StoreIntegrationInstanceRequest $request) + { + return (new IntegrationInstanceResource($this->service->create($request->validated()))) + ->response()->setStatusCode(201); + } + + public function show(IntegrationInstance $integrationInstance) + { + return new IntegrationInstanceResource($integrationInstance); + } + + public function update(StoreIntegrationInstanceRequest $request, IntegrationInstance $integrationInstance) + { + return new IntegrationInstanceResource($this->service->update($integrationInstance, $request->validated())); + } + + public function destroy(IntegrationInstance $integrationInstance) + { + $this->service->delete($integrationInstance); + + return response()->noContent(); + } +} diff --git a/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php b/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php new file mode 100644 index 0000000..b41bcee --- /dev/null +++ b/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php @@ -0,0 +1,39 @@ +integrations()->with('integrationInstance')->get()); + } + + public function show(WebsiteType $websiteType, string $integrationCode) + { + return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail()); + } + + public function store(AssociateIntegrationInstanceRequest $request, WebsiteType $websiteType, string $integrationCode) + { + return new IntegrationAssociationResource($this->service->associate( + $websiteType, $integrationCode, IntegrationInstance::findOrFail($request->validated('integration_instance_id')), + )); + } + + public function destroy(WebsiteType $websiteType, string $integrationCode) + { + $this->service->detach($websiteType, $integrationCode); + + return response()->noContent(); + } +} diff --git a/app/Domains/Integration/Policies/IntegrationPolicy.php b/app/Domains/Integration/Policies/IntegrationPolicy.php new file mode 100644 index 0000000..b4bff7c --- /dev/null +++ b/app/Domains/Integration/Policies/IntegrationPolicy.php @@ -0,0 +1,14 @@ +rol_codigo === RoleCode::Admin->value; + } +} diff --git a/app/Domains/Integration/Requests/AssociateIntegrationInstanceRequest.php b/app/Domains/Integration/Requests/AssociateIntegrationInstanceRequest.php new file mode 100644 index 0000000..254d286 --- /dev/null +++ b/app/Domains/Integration/Requests/AssociateIntegrationInstanceRequest.php @@ -0,0 +1,25 @@ +user()?->can('manage', Integration::class) ?? false; + } + + public function rules(): array + { + return [ + 'integration_instance_id' => [ + 'required', 'integer', + Rule::exists('integration_instances', 'id')->where('integration_code', $this->route('integration_code')), + ], + ]; + } +} diff --git a/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php b/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php index 40cd43c..d3484c0 100644 --- a/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php +++ b/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php @@ -12,7 +12,7 @@ class StoreClientIntegrationRequest extends FormRequest public function authorize(): bool { - return true; + return $this->user()?->can('manage', Integration::class) ?? false; } protected function prepareForValidation(): void @@ -31,7 +31,7 @@ class StoreClientIntegrationRequest extends FormRequest public function rules(): array { - $rules = []; + $rules = ['integration_data' => ['present', 'array']]; foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) { $rules['integration_data.'.$field] = $rule; diff --git a/app/Domains/Integration/Requests/StoreIntegrationInstanceRequest.php b/app/Domains/Integration/Requests/StoreIntegrationInstanceRequest.php new file mode 100644 index 0000000..f265d16 --- /dev/null +++ b/app/Domains/Integration/Requests/StoreIntegrationInstanceRequest.php @@ -0,0 +1,35 @@ +user()?->can('manage', Integration::class) ?? false; + } + + public function rules(): array + { + $instance = $this->route('integration_instance'); + $code = $instance?->integration_code ?? $this->input('integration_code'); + $integration = is_string($code) ? Integration::where('integration_code', $code)->first() : null; + $rules = [ + 'integration_code' => $instance ? ['prohibited'] : ['required', 'string', 'exists:integrations,integration_code'], + 'name' => [$instance ? 'sometimes' : 'required', 'required', 'string', 'max:255'], + // PUT/PATCH replaces the complete credential object when supplied. + 'integration_data' => [$instance ? 'sometimes' : 'present', 'array'], + ]; + + if (! $instance || $this->exists('integration_data')) { + foreach ($integration?->integration_data_schema ?? [] as $field => $rule) { + $rules['integration_data.'.$field] = $rule; + } + } + + return $rules; + } +} diff --git a/app/Domains/Integration/Requests/UpdateIntegrationRequest.php b/app/Domains/Integration/Requests/UpdateIntegrationRequest.php index 3c626ac..a2c466c 100644 --- a/app/Domains/Integration/Requests/UpdateIntegrationRequest.php +++ b/app/Domains/Integration/Requests/UpdateIntegrationRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Integration\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdateIntegrationRequest extends FormRequest { @@ -20,8 +21,7 @@ class UpdateIntegrationRequest extends FormRequest 'url' => ['nullable', 'url', 'max:255'], 'integration_data_schema' => ['nullable', 'array'], 'requires_configuration' => ['sometimes', 'boolean'], - // the code shouldn't ideally be updatable, but if it is: - 'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')], + 'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])], ]; } } diff --git a/app/Domains/Integration/Resources/IntegrationAssociationResource.php b/app/Domains/Integration/Resources/IntegrationAssociationResource.php new file mode 100644 index 0000000..31339ca --- /dev/null +++ b/app/Domains/Integration/Resources/IntegrationAssociationResource.php @@ -0,0 +1,21 @@ + $this->id, + 'client_id' => $this->when(isset($this->client_id), $this->client_id), + 'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code), + 'integration_code' => $this->integration_code, + 'integration_instance_id' => $this->integration_instance_id, + 'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')), + ]; + } +} diff --git a/app/Domains/Integration/Resources/IntegrationInstanceResource.php b/app/Domains/Integration/Resources/IntegrationInstanceResource.php new file mode 100644 index 0000000..a65f359 --- /dev/null +++ b/app/Domains/Integration/Resources/IntegrationInstanceResource.php @@ -0,0 +1,20 @@ + $this->id, + 'integration_code' => $this->integration_code, + 'name' => $this->name, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Integration/routes/api.php b/app/Domains/Integration/routes/api.php index 0fe672c..75e8baa 100644 --- a/app/Domains/Integration/routes/api.php +++ b/app/Domains/Integration/routes/api.php @@ -2,21 +2,37 @@ use App\Domains\Integration\Controllers\ClientIntegrationController; use App\Domains\Integration\Controllers\IntegrationController; +use App\Domains\Integration\Controllers\IntegrationInstanceController; use App\Domains\Integration\Controllers\TelepagosWebhookController; +use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController; +use App\Domains\Integration\Models\Integration; use Illuminate\Support\Facades\Route; -Route::group(['prefix' => 'integrations'], function () { - Route::get('/', [IntegrationController::class, 'index']); - Route::post('/', [IntegrationController::class, 'store']); - Route::get('/{integration}', [IntegrationController::class, 'show']); - Route::put('/{integration}', [IntegrationController::class, 'update']); - Route::delete('/{integration}', [IntegrationController::class, 'destroy']); -}); +Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void { + Route::apiResource('integration-instances', IntegrationInstanceController::class); -Route::group(['prefix' => 'clients/{client}/integrations'], function () { - Route::get('/', [ClientIntegrationController::class, 'index']); - Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']); - Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']); + Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void { + Route::get('/', [WebsiteTypeIntegrationController::class, 'index']); + Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']); + Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']); + Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']); + }); + + Route::group(['prefix' => 'integrations'], function () { + Route::get('/', [IntegrationController::class, 'index']); + Route::post('/', [IntegrationController::class, 'store']); + Route::get('/{integration}', [IntegrationController::class, 'show']); + Route::put('/{integration}', [IntegrationController::class, 'update']); + Route::delete('/{integration}', [IntegrationController::class, 'destroy']); + }); + + Route::group(['prefix' => 'clients/{client}/integrations'], function () { + Route::get('/', [ClientIntegrationController::class, 'index']); + Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']); + Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']); + Route::put('/{integration_code}/instance', [ClientIntegrationController::class, 'associate']); + Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']); + }); }); Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 557ef96..e687726 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Domains\Integration\Models\Integration; +use App\Domains\Integration\Policies\IntegrationPolicy; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\UserRegistered; use App\Domains\Notification\Listeners\SendPasswordResetEmail; @@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Gate::policy( + Integration::class, + IntegrationPolicy::class, + ); Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class); Event::listen(UserRegistered::class, SendWelcomeEmail::class); diff --git a/tests/Feature/Integration/ClientIntegrationControllerTest.php b/tests/Feature/Integration/ClientIntegrationControllerTest.php index fce9b5f..ab808e1 100644 --- a/tests/Feature/Integration/ClientIntegrationControllerTest.php +++ b/tests/Feature/Integration/ClientIntegrationControllerTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature\Integration; +use App\Domains\Auth\Models\User; use App\Domains\Client\Models\Client; use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Services\ClientIntegrationService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Sanctum\Sanctum; use Mockery; use Tests\TestCase; @@ -16,6 +18,7 @@ class ClientIntegrationControllerTest extends TestCase public function test_store_returns_success_message_without_integration_data(): void { + Sanctum::actingAs(User::factory()->create(['rol_codigo' => 'admin'])); $integration = Integration::create([ 'integration_code' => 'test_integration', 'name' => 'Test Integration', @@ -36,7 +39,7 @@ class ClientIntegrationControllerTest extends TestCase ->andReturn(new ClientIntegration); }); - $this->putJson('/api/clients/test-client/integrations/test_integration', [ + $this->withHeader('Accept-Language', 'es')->putJson('/api/clients/test-client/integrations/test_integration', [ 'integration_data' => [ 'api_key' => 'secret', ], diff --git a/tests/Feature/Integration/IntegrationInstanceTest.php b/tests/Feature/Integration/IntegrationInstanceTest.php new file mode 100644 index 0000000..60ccc33 --- /dev/null +++ b/tests/Feature/Integration/IntegrationInstanceTest.php @@ -0,0 +1,200 @@ + 'instance-test-secret']); + $this->integration = Integration::create([ + 'integration_code' => 'test', 'name' => 'Test', 'requires_configuration' => false, + 'integration_data_schema' => ['api_key' => 'required|string'], + ]); + } + + public function test_management_requires_an_authenticated_global_admin(): void + { + $this->getJson('/api/integration-instances')->assertUnauthorized(); + Sanctum::actingAs(User::factory()->create()); + $this->getJson('/api/integration-instances')->assertForbidden(); + $this->postJson('/api/integration-instances', [])->assertForbidden(); + $this->getJson('/api/integrations')->assertForbidden(); + } + + public function test_instance_crud_validates_configuration_and_never_returns_secrets(): void + { + $this->admin(); + $this->postJson('/api/integration-instances', [ + 'integration_code' => 'test', 'name' => 'Invalid', 'integration_data' => [], + ])->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key'); + $response = $this->postJson('/api/integration-instances', [ + 'integration_code' => 'test', 'name' => 'Shared', 'integration_data' => ['api_key' => 'secret'], + ])->assertCreated()->assertJsonMissingPath('data.integration_data'); + $id = $response->json('data.id'); + $this->getJson('/api/integration-instances/'.$id)->assertOk()->assertJsonMissingPath('data.integration_data'); + $this->patchJson('/api/integration-instances/'.$id, ['name' => 'Renamed'])->assertOk(); + self::assertSame('secret', IntegrationInstance::findOrFail($id)->integration_data['api_key']); + $this->patchJson('/api/integration-instances/'.$id, ['integration_code' => 'test'])->assertUnprocessable(); + $this->patchJson('/api/integration-instances/'.$id, ['integration_data' => []])->assertUnprocessable(); + $this->patchJson('/api/integration-instances/'.$id, ['integration_data' => ['api_key' => 'new']])->assertOk(); + self::assertSame('new', IntegrationInstance::findOrFail($id)->integration_data['api_key']); + $this->deleteJson('/api/integration-instances/'.$id)->assertNoContent(); + } + + public function test_association_api_checks_integration_and_protects_linked_instances(): void + { + $this->admin(); + $client = Client::create(['code' => 'acme', 'name' => 'Acme']); + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $instance = $this->makeInstance('shared'); + $this->putJson('/api/clients/acme/integrations/wrong/instance', ['integration_instance_id' => $instance->id])->assertUnprocessable(); + $this->putJson('/api/clients/acme/integrations/test/instance', ['integration_instance_id' => $instance->id]) + ->assertCreated()->assertJsonPath('data.integration_instance.name', 'shared')->assertJsonMissingPath('data.integration_instance.integration_data'); + $this->putJson('/api/website-types/demo/integrations/test', ['integration_instance_id' => $instance->id])->assertCreated(); + $this->getJson('/api/website-types/demo/integrations/test')->assertOk()->assertJsonPath('data.integration_instance_id', $instance->id); + $this->deleteJson('/api/integration-instances/'.$instance->id)->assertConflict(); + $this->deleteJson('/api/clients/acme/integrations/test')->assertNoContent(); + $this->deleteJson('/api/website-types/demo/integrations/test')->assertNoContent(); + self::assertTrue($instance->fresh()->exists); + $this->deleteJson('/api/integration-instances/'.$instance->id)->assertNoContent(); + } + + public function test_tenant_prefers_client_then_type_and_client_context_does_not_inherit(): void + { + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $tenant = $this->tenantForType($type); + $associations = new IntegrationAssociationService; + $associations->associate($type, 'test', $this->makeInstance('type')); + $service = $this->probe(); + self::assertSame('type', $service->forTenant($tenant->codigo)->setting()); + self::assertNull($service->forClient($tenant->client)->setting()); + $associations->associate($tenant->client, 'test', $this->makeInstance('client')); + self::assertSame('client', $service->forTenant($tenant->codigo)->setting()); + $associations->detach($tenant->client, 'test'); + self::assertSame('type', $service->forTenant($tenant->codigo)->setting()); + $associations->detach($type, 'test'); + self::assertNull($service->forTenant($tenant->codigo)->setting()); + $this->integration->update(['requires_configuration' => true]); + $this->expectException(\Exception::class); + $service->forTenant($tenant->codigo); + } + + public function test_required_configuration_can_come_from_the_website_type(): void + { + $this->integration->update(['requires_configuration' => true]); + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $tenant = $this->tenantForType($type); + (new IntegrationAssociationService)->associate($type, 'test', $this->makeInstance('type')); + self::assertSame('type', $this->probe()->forTenant($tenant->codigo)->setting()); + } + + public function test_legacy_save_does_not_modify_a_shared_instance(): void + { + $a = Client::create(['code' => 'a', 'name' => 'A']); + $b = Client::create(['code' => 'b', 'name' => 'B']); + $shared = $this->makeInstance('shared'); + $associations = new IntegrationAssociationService; + $associations->associate($a, 'test', $shared); + $associations->associate($b, 'test', $shared); + (new ClientIntegrationService)->updateOrCreateIntegration($a, $this->integration, ['api_key' => 'private']); + self::assertSame('private', $this->probe()->forClient($a)->setting()); + self::assertSame('shared', $this->probe()->forClient($b)->setting()); + (new IntegrationInstanceService)->update($shared, ['integration_data' => ['api_key' => 'changed']]); + self::assertSame('changed', $this->probe()->forClient($b)->setting()); + } + + public function test_telepagos_shares_tokens_by_instance_and_refreshes_after_credential_changes(): void + { + Integration::create(['integration_code' => 'telepagos_homo', 'name' => 'Telepagos', 'url' => 'https://payments.test']); + $instance = IntegrationInstance::create([ + 'integration_code' => 'telepagos_homo', 'name' => 'Payments', + 'integration_data' => ['username' => 'first', 'password' => 'secret'], + ]); + $a = Client::create(['code' => 'a', 'name' => 'A']); + $b = Client::create(['code' => 'b', 'name' => 'B']); + $associations = new IntegrationAssociationService; + $associations->associate($a, 'telepagos_homo', $instance); + $associations->associate($b, 'telepagos_homo', $instance); + Cache::flush(); + Http::fake(['https://payments.test/v2/auth/token' => Http::sequence() + ->push(['status' => 'ok', 'token' => 'first-token', 'expires_at' => now()->addHour()->toDateTimeString()]) + ->push(['status' => 'ok', 'token' => 'new-token', 'expires_at' => now()->addHour()->toDateTimeString()])]); + self::assertSame('first-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($a)->getToken()); + self::assertSame('first-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($b)->getToken()); + Http::assertSentCount(1); + (new IntegrationInstanceService)->update($instance, ['integration_data' => ['username' => 'second', 'password' => 'new-secret']]); + self::assertSame('new-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($b)->getToken()); + Http::assertSentCount(2); + } + + private function tenantForType(WebsiteType $type): Tenant + { + $logo = Attachment::create([ + 'path' => 'tenants/logo.png', 'filename' => 'logo.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::create([ + 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.test', + 'website_type_code' => $type->codigo, + 'primary_color' => '#112233', 'secondary_color' => '#445566', + 'danger_color' => '#ff0000', 'success_color' => '#00ff00', + 'header_bg_color' => '#112233', 'footer_bg_color' => '#112233', + 'header_logo_id' => $logo->id, 'footer_logo_id' => $logo->id, + ]); + } + + private function makeInstance(string $name): IntegrationInstance + { + return IntegrationInstance::create(['integration_code' => 'test', 'name' => $name, 'integration_data' => ['api_key' => $name]]); + } + + private function admin(): void + { + Sanctum::actingAs(User::factory()->create(['rol_codigo' => 'admin'])); + } + + private function probe(): BaseIntegrationService + { + return new class extends BaseIntegrationService + { + protected string $integrationCode = 'test'; + + public function getHeaders(): array + { + return []; + } + + public function setting(): mixed + { + return $this->getIntegrationSetting('api_key'); + } + }; + } +}