true, 'suitecrm.base_url' => 'https://crm.example.invalid/legacy', 'suitecrm.client_id' => 'test-client', 'suitecrm.client_secret' => 'secret-not-for-logs', 'suitecrm.cache_store' => 'array', 'suitecrm.retry_delay_ms' => 0]); Cache::store('array')->flush(); Http::preventStrayRequests(); Log::spy(); Sleep::fake(); $this->client = new SuiteCrmClient; } private function tokenResponse(string $token = 'test-token'): array { return ['access_token' => $token, 'token_type' => 'Bearer', 'expires_in' => 3600]; } private function tokenKey(): string { return 'suitecrm:'.hash('sha256', 'https://crm.example.invalid/legacy|test-client|secret-not-for-logs').':token'; } public function test_disabled_client_and_insecure_configuration_never_send_requests(): void { Http::fake(); config(['suitecrm.enabled' => false]); try { $this->client->find('Contacts', 'contact-1'); $this->fail('Expected disabled client'); } catch (SuiteCrmException $e) { $this->assertSame('disabled', $e->reason); } config(['suitecrm.enabled' => true]); foreach (['http://crm.example.invalid', 'https://user:pass@crm.example.invalid', 'https://crm.example.invalid?token=secret'] as $url) { config(['suitecrm.base_url' => $url]); try { $this->client->find('Contacts', 'contact-1'); $this->fail('Expected URL rejection'); } catch (SuiteCrmException $e) { $this->assertSame('invalid_base_url', $e->reason); } } Http::assertNothingSent(); } public function test_authentication_contract_and_token_cache_are_encrypted(): void { Http::fake([ '*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/module/Contacts/contact-1' => Http::response(['data' => ['id' => 'contact-1', 'type' => 'Contacts']]), ]); $this->client->find('Contacts', 'contact-1'); $this->client->find('Contacts', 'contact-1'); Http::assertSentCount(3); Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/Api/access_token') && $r['grant_type'] === 'client_credentials' && $r['client_id'] === 'test-client' && $r['client_secret'] === 'secret-not-for-logs'); Http::assertSent(fn (Request $r) => str_contains($r->url(), '/Api/V8/') && $r->hasHeader('Authorization', 'Bearer test-token')); $encrypted = Cache::store('array')->get($this->tokenKey()); $this->assertNotSame('test-token', $encrypted); $this->assertSame('test-token', Crypt::decryptString($encrypted)); } public function test_401_renews_once_and_replays_the_unauthorised_request(): void { Http::fake([ '*/Api/access_token' => Http::sequence()->push($this->tokenResponse('old'))->push($this->tokenResponse('new')), '*/Api/V8/*' => Http::sequence()->push([], 401)->push(['data' => ['id' => 'contact-1']]), ]); $result = $this->client->find('Contacts', 'contact-1'); $this->assertSame('contact-1', $result['data']['id']); Http::assertSentCount(4); Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'Bearer new')); } public function test_second_401_is_not_retried(): void { Http::fake([ '*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::response(['errors' => [['detail' => 'personal@example.invalid']]], 401), ]); try { $this->client->find('Contacts', 'contact-1'); $this->fail('Expected authentication failure'); } catch (SuiteCrmException $e) { $this->assertSame('unauthenticated', $e->reason); $this->assertFalse($e->retryable); $this->assertStringNotContainsString('personal@', $e->getMessage()); $this->assertNull($e->getPrevious()); } Http::assertSentCount(4); } public function test_json_api_create_update_relationship_and_exact_search_contracts(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*' => Http::response(['data' => ['id' => 'record-1']])]); $this->client->create('Accounts', ['name' => 'Example']); $this->client->update('Accounts', 'account-1', ['name' => 'Revised']); $this->client->relate('Accounts', 'account-1', 'contacts', 'Contacts', 'contact-1'); $this->client->search('Contacts', ['email1' => 'person@example.invalid'], 2, 10); Http::assertSent(fn (Request $r) => $r->method() === 'POST' && str_ends_with($r->url(), '/V8/module') && $r['data'] === ['type' => 'Accounts', 'attributes' => ['name' => 'Example']]); Http::assertSent(fn (Request $r) => $r->method() === 'PATCH' && $r['data']['id'] === 'account-1'); Http::assertSent(fn (Request $r) => str_ends_with($r->url(), '/Accounts/account-1/relationships/contacts') && $r['data'] === ['type' => 'Contacts', 'id' => 'contact-1']); Http::assertSent(fn (Request $r) => $r->method() === 'GET' && $r['filter']['email1']['eq'] === 'person@example.invalid' && $r['page']['number'] === 2); } public function test_read_retries_transient_failures_but_create_does_not(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::sequence()->push([], 503)->push([], 503)->push(['data' => []])->push([], 503)]); $this->assertSame(['data' => []], $this->client->search('Contacts', [])); try { $this->client->create('Leads', ['last_name' => 'Example']); $this->fail('Expected uncertain write'); } catch (SuiteCrmException $e) { $this->assertTrue($e->outcomeUnknown); $this->assertSame(503, $e->httpStatus); } Http::assertSentCount(5); } public function test_write_timeout_is_not_replayed_and_transport_exception_is_not_retained(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::failedConnection('secret-not-for-logs')]); try { $this->client->create('Leads', ['last_name' => 'Private Name']); $this->fail('Expected transport failure'); } catch (SuiteCrmException $e) { $this->assertTrue($e->outcomeUnknown); $this->assertSame('transport_failure', $e->reason); $this->assertNull($e->getPrevious()); $this->assertStringNotContainsString('secret-not-for-logs', $e->getMessage()); } Log::shouldHaveReceived('info')->with('suitecrm.http', \Mockery::on(fn ($context) => array_keys($context) === ['operation', 'status', 'duration_ms']))->twice(); } public function test_retry_after_is_returned_without_immediate_retry_for_long_delays(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::response([], 429, ['Retry-After' => '120'])]); try { $this->client->search('Contacts', []); $this->fail('Expected rate limit'); } catch (SuiteCrmException $e) { $this->assertSame(120, $e->retryAfter); $this->assertSame('rate_limited', $e->reason); } Http::assertSentCount(2); } public function test_circuit_opens_then_allows_recovery_after_cooldown(): void { config(['suitecrm.circuit_threshold' => 1, 'suitecrm.read_retries' => 0]); Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::sequence()->push([], 503)->push(['data' => []])]); foreach (['unavailable', 'circuit_open'] as $expected) { try { $this->client->search('Contacts', []); $this->fail('Expected circuit failure'); } catch (SuiteCrmException $e) { $this->assertSame($expected, $e->reason); } } Http::assertSentCount(2); $this->travel(61)->seconds(); $this->assertSame(['data' => []], $this->client->search('Contacts', [])); Http::assertSentCount(3); } public function test_identifiers_modules_and_page_limits_are_rejected_before_network(): void { Http::fake(); foreach ([fn () => $this->client->find('Users', '1'), fn () => $this->client->find('Contacts', '../Accounts'), fn () => $this->client->search('Contacts', [], 1, 1000)] as $operation) { try { $operation(); $this->fail('Expected rejection'); } catch (SuiteCrmException $e) { $this->assertContains($e->reason, ['module_not_allowed', 'invalid_identifier', 'invalid_pagination']); } } Http::assertNothingSent(); } public function test_invalid_token_response_and_redirect_are_rejected(): void { Http::fake(['*/Api/access_token' => Http::sequence()->push(['access_token' => 'secret', 'expires_in' => 0])->push($this->tokenResponse()), '*/Api/V8/*' => Http::response('', 302, ['Location' => 'https://untrusted.example.invalid'])]); try { $this->client->find('Contacts', 'contact-1'); $this->fail('Expected invalid token'); } catch (SuiteCrmException $e) { $this->assertSame('invalid_token_response', $e->reason); } try { $this->client->find('Contacts', 'contact-1'); $this->fail('Expected redirect rejection'); } catch (SuiteCrmException $e) { $this->assertSame('unexpected_status', $e->reason); $this->assertSame(302, $e->httpStatus); } } public function test_token_expiration_and_credential_rotation_obtain_new_tokens(): void { Http::fake(['*/Api/access_token' => Http::sequence()->push($this->tokenResponse('first')) ->push($this->tokenResponse('renewed'))->push($this->tokenResponse('rotated')), '*/Api/V8/*' => Http::response(['data' => []])]); $this->client->search('Contacts', []); $this->travel(3601)->seconds(); $this->client->search('Contacts', []); config(['suitecrm.client_secret' => 'rotated-secret']); $this->client->search('Contacts', []); Http::assertSentCount(6); Http::assertSent(fn (Request $r) => $r->hasHeader('Authorization', 'Bearer rotated')); } public function test_forbidden_and_invalid_input_responses_are_permanent_and_sanitised(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::sequence()->push(['errors' => [['detail' => 'private@example.invalid']]], 403) ->push(['errors' => [['detail' => 'Private Name']]], 422)]); foreach (['forbidden', 'validation_failed'] as $reason) { try { $this->client->create('Contacts', ['last_name' => 'Private Name']); $this->fail('Expected permanent failure'); } catch (SuiteCrmException $e) { $this->assertSame($reason, $e->reason); $this->assertFalse($e->retryable); $this->assertFalse($e->outcomeUnknown); $this->assertNull($e->getPrevious()); } } Http::assertSentCount(3); } public function test_a_malformed_successful_write_is_treated_as_ambiguous(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::response('Private data', 200)]); try { $this->client->create('Contacts', ['last_name' => 'Private Name']); $this->fail('Expected invalid response'); } catch (SuiteCrmException $e) { $this->assertSame('invalid_response', $e->reason); $this->assertTrue($e->outcomeUnknown); } Http::assertSentCount(2); } public function test_retry_after_short_delay_is_respected_and_logs_have_no_payload(): void { Http::fake(['*/Api/access_token' => Http::response($this->tokenResponse()), '*/Api/V8/*' => Http::sequence()->push([], 429, ['Retry-After' => '2'])->push(['data' => []])]); $this->client->search('Contacts', ['email1' => 'private@example.invalid']); Sleep::assertSlept(function (CarbonInterval $duration) { return $duration->totalMilliseconds >= 2000; }); Log::shouldHaveReceived('info')->with('suitecrm.http', \Mockery::on(fn ($context) => array_keys($context) === ['operation', 'status', 'duration_ms']))->times(3); } }