'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'); } }; } }