diff --git a/Classes/Service/AnalyticsStatusService.php b/Classes/Service/AnalyticsStatusService.php index b3379dd..4ee03ab 100644 --- a/Classes/Service/AnalyticsStatusService.php +++ b/Classes/Service/AnalyticsStatusService.php @@ -9,6 +9,7 @@ use Psr\Log\LoggerInterface; use T3G\Analytics\Exception\AnalyticsApiException; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteSettingsFactory; use TYPO3\CMS\Core\Site\SiteSettingsService; @@ -25,6 +26,7 @@ public function __construct( private LoggerInterface $logger, private SiteSettingsService $siteSettingsService, private SiteSettingsFactory $siteSettingsFactory, + private SiteSettingsWriteVerifierInterface $writeGuard, ) { } @@ -110,7 +112,26 @@ public function syncSiteSettingsFromStatus(Site $site, array $data): void } $existing = $this->siteSettingsFactory->loadLocalSettings($site->getIdentifier()) ?? []; - $this->siteSettingsService->writeSettings($site, array_merge($existing, $update)); + try { + $this->siteSettingsService->writeSettings($site, array_merge($existing, $update)); + } catch (SiteConfigurationWriteException $e) { + $this->logger->warning( + 'syncSiteSettingsFromStatus: writeSettings threw. Check file system permissions.', + ['siteIdentifier' => $site->getIdentifier(), 'exception' => $e->getMessage()] + ); + return; + } + + try { + $this->writeGuard->assertSettingsPersisted($site, $update); + } catch (AnalyticsApiException) { + $this->logger->warning( + 'syncSiteSettingsFromStatus: settings could not be persisted. Check file system permissions.', + ['siteIdentifier' => $site->getIdentifier()] + ); + return; + } + $this->logger->info( 'Site settings updated from status response.', ['siteIdentifier' => $site->getIdentifier(), 'update' => $update] diff --git a/Classes/Service/ApiKeyService.php b/Classes/Service/ApiKeyService.php index af4f036..a0e8f1a 100644 --- a/Classes/Service/ApiKeyService.php +++ b/Classes/Service/ApiKeyService.php @@ -8,6 +8,7 @@ use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use T3G\Analytics\Exception\AnalyticsApiException; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteSettingsFactory; use TYPO3\CMS\Core\Site\SiteSettingsService; @@ -19,6 +20,7 @@ public function __construct( private CipherServiceInterface $cipherService, private SiteSettingsService $siteSettingsService, private SiteSettingsFactory $siteSettingsFactory, + private SiteSettingsWriteVerifierInterface $writeGuard, private LoggerInterface $logger, ) { } @@ -102,10 +104,28 @@ public function provisionIfNeeded(Site $site, array $currentStatus): void } $existing = $this->siteSettingsFactory->loadLocalSettings($siteIdentifier) ?? []; - $this->siteSettingsService->writeSettings($site, array_merge($existing, [ - 'apiKeyId' => $result['apiKeyId'], - 'apiKey' => $encryptedApiKey, - ])); + try { + $this->siteSettingsService->writeSettings($site, array_merge($existing, [ + 'apiKeyId' => $result['apiKeyId'], + 'apiKey' => $encryptedApiKey, + ])); + } catch (SiteConfigurationWriteException $e) { + $this->logger->error('ApiKeyService: Failed to write API key settings to site configuration.', [ + 'siteIdentifier' => $siteIdentifier, + 'exception' => $e->getMessage(), + ]); + return; + } + + try { + $this->writeGuard->assertSettingsPersisted($site, ['apiKeyId' => $result['apiKeyId']]); + } catch (AnalyticsApiException $e) { + $this->logger->error('ApiKeyService: API key could not be persisted.', [ + 'siteIdentifier' => $siteIdentifier, + 'apiKeyId' => $result['apiKeyId'], + ]); + return; + } $this->logger->info('ApiKeyService: API key provisioned.', [ 'siteIdentifier' => $siteIdentifier, diff --git a/Classes/Service/InstanceRegistrationService.php b/Classes/Service/InstanceRegistrationService.php index 394eaf8..e3cab37 100644 --- a/Classes/Service/InstanceRegistrationService.php +++ b/Classes/Service/InstanceRegistrationService.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use T3G\Analytics\Exception\AnalyticsApiException; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Site\Entity\Site; use TYPO3\CMS\Core\Site\SiteSettingsFactory; use TYPO3\CMS\Core\Site\SiteSettingsService; @@ -17,6 +18,7 @@ public function __construct( private CipherServiceInterface $cipherService, private SiteSettingsService $siteSettingsService, private SiteSettingsFactory $siteSettingsFactory, + private SiteSettingsWriteVerifierInterface $writeGuard, private LoggerInterface $logger, ) { } @@ -31,6 +33,8 @@ public function register(Site $site, string $email): void { $siteIdentifier = $site->getIdentifier(); + $this->writeGuard->assertDirectoryWritable($site); + try { $data = $this->apiClient->registerInstance($site, $email); } catch (AnalyticsApiException $e) { @@ -51,11 +55,23 @@ public function register(Site $site, string $email): void $encryptedSecret = $instanceSecret !== '' ? $this->cipherService->encrypt($instanceSecret) : ''; $existing = $this->siteSettingsFactory->loadLocalSettings($siteIdentifier) ?? []; - $this->siteSettingsService->writeSettings($site, array_merge($existing, [ - 'websiteId' => $websiteId, - 'instanceId' => $instanceId, - 'instanceSecret' => $encryptedSecret, - ])); + try { + $this->siteSettingsService->writeSettings($site, array_merge($existing, [ + 'websiteId' => $websiteId, + 'instanceId' => $instanceId, + 'instanceSecret' => $encryptedSecret, + ])); + } catch (SiteConfigurationWriteException $e) { + $this->logger->error('Registration: writeSettings threw.', ['siteIdentifier' => $siteIdentifier, 'exception' => $e->getMessage()]); + throw new AnalyticsApiException('Settings could not be written to config/sites/' . $siteIdentifier . '/settings.yaml. Check file system permissions for this directory.', 0); + } + + try { + $this->writeGuard->assertSettingsPersisted($site, ['websiteId' => $websiteId]); + } catch (AnalyticsApiException $e) { + $this->logger->error('Registration: settings could not be persisted.', ['siteIdentifier' => $siteIdentifier]); + throw $e; + } $this->logger->info('Site successfully registered.', ['siteIdentifier' => $siteIdentifier, 'websiteId' => $websiteId]); } diff --git a/Classes/Service/SiteSettingsWriteVerifier.php b/Classes/Service/SiteSettingsWriteVerifier.php new file mode 100644 index 0000000..22486cb --- /dev/null +++ b/Classes/Service/SiteSettingsWriteVerifier.php @@ -0,0 +1,47 @@ +getIdentifier(); + $configDir = Environment::getConfigPath() . '/sites/' . $identifier; + + if (!is_dir($configDir) || !is_writable($configDir)) { + throw new AnalyticsApiException( + 'Cannot write to site configuration directory for "' . $identifier . '". Check file system permissions.', + 0 + ); + } + } + + public function assertSettingsPersisted(Site $site, array $expected): void + { + $identifier = $site->getIdentifier(); + $persisted = $this->siteSettingsFactory->loadLocalSettings($identifier) ?? []; + + foreach ($expected as $key => $value) { + if (($persisted[$key] ?? '') !== $value) { + throw new AnalyticsApiException( + 'Settings for site "' . $identifier . '" could not be persisted. Check file system permissions.', + 0 + ); + } + } + } + +} diff --git a/Classes/Service/SiteSettingsWriteVerifierInterface.php b/Classes/Service/SiteSettingsWriteVerifierInterface.php new file mode 100644 index 0000000..4eea638 --- /dev/null +++ b/Classes/Service/SiteSettingsWriteVerifierInterface.php @@ -0,0 +1,27 @@ + $expected key-value pairs that must appear in the persisted settings + * @throws AnalyticsApiException + */ + public function assertSettingsPersisted(Site $site, array $expected): void; +} diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index fa2d07a..b9d16de 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -12,6 +12,9 @@ services: - '../Classes/Dashboard/Widget/TrafficGraphWidgetV14.php' - '../Classes/Dashboard/Widget/TrafficSourcesWidgetV14.php' + T3G\Analytics\Service\SiteSettingsWriteVerifierInterface: + alias: T3G\Analytics\Service\SiteSettingsWriteVerifier + T3G\Analytics\Service\TopPagesServiceInterface: alias: T3G\Analytics\Service\TopPagesService diff --git a/Tests/Functional/Controller/BackendModuleControllerTest.php b/Tests/Functional/Controller/BackendModuleControllerTest.php index 1518419..39ba3db 100644 --- a/Tests/Functional/Controller/BackendModuleControllerTest.php +++ b/Tests/Functional/Controller/BackendModuleControllerTest.php @@ -22,6 +22,7 @@ use T3G\Analytics\Service\ApiKeyService; use T3G\Analytics\Service\InstanceRegistrationService; use T3G\Analytics\Service\SiteDataProvider; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use T3G\Analytics\Service\BackendPageAccessCheckerInterface; use T3G\Analytics\Tests\Functional\Bootstrap\FunctionalTestCase; use TYPO3\CMS\Backend\Routing\Route; @@ -415,6 +416,7 @@ private function buildController( new NullLogger(), $this->createMock(SiteSettingsService::class), $this->createMock(SiteSettingsFactory::class), + $this->createMock(SiteSettingsWriteVerifierInterface::class), ); $siteDataProvider = new SiteDataProvider( @@ -436,6 +438,7 @@ private function buildController( $cipherService, $this->createMock(SiteSettingsService::class), $this->createMock(SiteSettingsFactory::class), + $this->createMock(SiteSettingsWriteVerifierInterface::class), new NullLogger(), ); @@ -444,6 +447,7 @@ private function buildController( $cipherService, $this->createMock(SiteSettingsService::class), $this->createMock(SiteSettingsFactory::class), + $this->createMock(SiteSettingsWriteVerifierInterface::class), new NullLogger(), ); diff --git a/Tests/Functional/Service/AnalyticsStatusServiceTest.php b/Tests/Functional/Service/AnalyticsStatusServiceTest.php index aec684d..44ddf0f 100644 --- a/Tests/Functional/Service/AnalyticsStatusServiceTest.php +++ b/Tests/Functional/Service/AnalyticsStatusServiceTest.php @@ -15,6 +15,7 @@ use T3G\Analytics\Service\ApiExceptionExtractor; use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use T3G\Analytics\Tests\Functional\Bootstrap\FunctionalTestCase; use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; @@ -73,6 +74,7 @@ protected function setUp(): void new \Psr\Log\NullLogger(), $this->createMock(SiteSettingsService::class), $this->createMock(SiteSettingsFactory::class), + $this->createMock(SiteSettingsWriteVerifierInterface::class), ); } diff --git a/Tests/Functional/Service/InstanceRegistrationServiceTest.php b/Tests/Functional/Service/InstanceRegistrationServiceTest.php index edc4a77..33f3deb 100644 --- a/Tests/Functional/Service/InstanceRegistrationServiceTest.php +++ b/Tests/Functional/Service/InstanceRegistrationServiceTest.php @@ -16,6 +16,7 @@ use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; use T3G\Analytics\Service\InstanceRegistrationService; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use T3G\Analytics\Tests\Functional\Bootstrap\FunctionalTestCase; use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; use TYPO3\CMS\Core\Http\RequestFactory; @@ -75,6 +76,7 @@ protected function setUp(): void $this->cipherService, $this->siteSettingsService, $this->siteSettingsFactory, + $this->createMock(SiteSettingsWriteVerifierInterface::class), new \Psr\Log\NullLogger(), ); } diff --git a/Tests/Functional/Service/SiteSettingsWriteVerifierTest.php b/Tests/Functional/Service/SiteSettingsWriteVerifierTest.php new file mode 100644 index 0000000..0a71a96 --- /dev/null +++ b/Tests/Functional/Service/SiteSettingsWriteVerifierTest.php @@ -0,0 +1,96 @@ +siteSettingsFactory = $this->createMock(SiteSettingsFactory::class); + } + + #[Test] + public function assertSettingsPersistedDoesNotThrowWhenAllValuesMatch(): void + { + $this->expectNotToPerformAssertions(); + + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([ + 'websiteId' => 'w-123', + 'instanceId' => 'i-456', + ]); + $verifier = new SiteSettingsWriteVerifier($this->siteSettingsFactory); + + $verifier->assertSettingsPersisted($this->buildSite(), ['websiteId' => 'w-123', 'instanceId' => 'i-456']); + } + + #[Test] + public function assertSettingsPersistedThrowsWhenKeyIsAbsent(): void + { + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $verifier = new SiteSettingsWriteVerifier($this->siteSettingsFactory); + + $this->expectException(AnalyticsApiException::class); + $this->expectExceptionMessageMatches('/"main"/'); + + $verifier->assertSettingsPersisted($this->buildSite(), ['websiteId' => 'w-123']); + } + + #[Test] + public function assertSettingsPersistedThrowsWhenValueDoesNotMatch(): void + { + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn(['websiteId' => 'other-id']); + $verifier = new SiteSettingsWriteVerifier($this->siteSettingsFactory); + + $this->expectException(AnalyticsApiException::class); + + $verifier->assertSettingsPersisted($this->buildSite(), ['websiteId' => 'w-123']); + } + + #[Test] + public function assertSettingsPersistedThrowsOnFirstFailingKey(): void + { + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn(['websiteId' => 'w-123']); + $verifier = new SiteSettingsWriteVerifier($this->siteSettingsFactory); + + $this->expectException(AnalyticsApiException::class); + + $verifier->assertSettingsPersisted($this->buildSite(), ['websiteId' => 'w-123', 'instanceId' => 'i-456']); + } + + #[Test] + public function assertSettingsPersistedReadsSettingsOnlyOnce(): void + { + $this->siteSettingsFactory + ->expects(self::once()) + ->method('loadLocalSettings') + ->willReturn(['websiteId' => 'w-123', 'instanceId' => 'i-456']); + + $verifier = new SiteSettingsWriteVerifier($this->siteSettingsFactory); + $verifier->assertSettingsPersisted($this->buildSite(), ['websiteId' => 'w-123', 'instanceId' => 'i-456']); + } + + private function buildSite(): Site + { + $site = $this->createMock(Site::class); + $site->method('getIdentifier')->willReturn('main'); + $site->method('getBase')->willReturn(new Uri('https://example.com')); + $site->method('getSettings')->willReturn(new SiteSettings(new Settings([]), [], [])); + return $site; + } +} diff --git a/Tests/Unit/Controller/BackendModuleControllerTest.php b/Tests/Unit/Controller/BackendModuleControllerTest.php index 2fa4e1e..ac49de2 100644 --- a/Tests/Unit/Controller/BackendModuleControllerTest.php +++ b/Tests/Unit/Controller/BackendModuleControllerTest.php @@ -24,6 +24,7 @@ use T3G\Analytics\Service\HmacSigner; use T3G\Analytics\Service\InstanceRegistrationService; use T3G\Analytics\Service\SiteDataProvider; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use T3G\Analytics\Service\BackendPageAccessCheckerInterface; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; @@ -136,6 +137,7 @@ protected function setUp(): void new NullLogger(), $this->siteSettingsService, $this->siteSettingsFactory, + $this->createMock(SiteSettingsWriteVerifierInterface::class), ); $registrationService = new InstanceRegistrationService( @@ -143,6 +145,7 @@ protected function setUp(): void $cipherService, $this->siteSettingsService, $this->siteSettingsFactory, + $this->createMock(SiteSettingsWriteVerifierInterface::class), new NullLogger(), ); @@ -151,6 +154,7 @@ protected function setUp(): void $cipherService, $this->siteSettingsService, $this->siteSettingsFactory, + $this->createMock(SiteSettingsWriteVerifierInterface::class), new NullLogger(), ); diff --git a/Tests/Unit/Service/AnalyticsStatusServiceTest.php b/Tests/Unit/Service/AnalyticsStatusServiceTest.php index 86e2ca8..a3abb06 100644 --- a/Tests/Unit/Service/AnalyticsStatusServiceTest.php +++ b/Tests/Unit/Service/AnalyticsStatusServiceTest.php @@ -12,13 +12,16 @@ use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\NullLogger; use T3G\Analytics\Configuration\ApiConfiguration; +use T3G\Analytics\Exception\AnalyticsApiException; use T3G\Analytics\Service\AnalyticsApiClient; use T3G\Analytics\Service\AnalyticsStatusService; use T3G\Analytics\Service\ApiExceptionExtractor; use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; use TYPO3\CMS\Core\Http\RequestFactory; @@ -38,6 +41,7 @@ final class AnalyticsStatusServiceTest extends UnitTestCase private array $httpHistory = []; private SiteSettingsService&MockObject $siteSettingsService; private SiteSettingsFactory&MockObject $siteSettingsFactory; + private SiteSettingsWriteVerifierInterface&MockObject $writeGuard; private VariableFrontend $cache; private string $encryptedTestSecret; @@ -58,6 +62,7 @@ protected function setUp(): void $this->siteSettingsService = $this->createMock(SiteSettingsService::class); $this->siteSettingsFactory = $this->createMock(SiteSettingsFactory::class); + $this->writeGuard = $this->createMock(SiteSettingsWriteVerifierInterface::class); // TransientMemoryBackend dropped the $context parameter in TYPO3 v14. $backend = (new Typo3Version())->getMajorVersion() >= 14 ? new TransientMemoryBackend() // @phpstan-ignore argument.count @@ -84,6 +89,7 @@ protected function setUp(): void new NullLogger(), $this->siteSettingsService, $this->siteSettingsFactory, + $this->writeGuard, ); } @@ -231,6 +237,39 @@ public function skipsApiCallOnCacheHit(): void self::assertEmpty($this->httpHistory); } + #[Test] + public function syncSiteSettingsFromStatusLogsWarningWhenWriteSettingsThrows(): void + { + $site = $this->buildSite('main', 'w-123', 'i-456'); + $this->mockHandler->append(new Response(200, [], '{"status":"active","maxPrivacyModeTrackingCode":"tc-abc"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->siteSettingsService + ->method('writeSettings') + ->willThrowException(new SiteConfigurationWriteException('disk full', 1590487411)); + + $status = $this->subject->getStatus($site, forceRefresh: true); + $this->subject->syncSiteSettingsFromStatus($site, $status ?? []); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function syncSiteSettingsFromStatusLogsWarningWhenSettingsCouldNotBePersisted(): void + { + $site = $this->buildSite('main', 'w-123', 'i-456'); + $this->mockHandler->append(new Response(200, [], '{"status":"active","maxPrivacyModeTrackingCode":"tc-abc"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->writeGuard + ->method('assertSettingsPersisted') + ->willThrowException(new AnalyticsApiException('Credentials could not be written.', 0)); + + $status = $this->subject->getStatus($site, forceRefresh: true); + $this->subject->syncSiteSettingsFromStatus($site, $status ?? []); + + // No exception propagated — the service warns and returns. + $this->expectNotToPerformAssertions(); + } + /** Helpers */ private function buildSite(string $identifier, string $websiteId, string $instanceId, array $extraSettings = []): Site diff --git a/Tests/Unit/Service/ApiKeyServiceTest.php b/Tests/Unit/Service/ApiKeyServiceTest.php index dcbb762..1c4d15a 100644 --- a/Tests/Unit/Service/ApiKeyServiceTest.php +++ b/Tests/Unit/Service/ApiKeyServiceTest.php @@ -12,11 +12,14 @@ use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\NullLogger; use T3G\Analytics\Configuration\ApiConfiguration; +use T3G\Analytics\Exception\AnalyticsApiException; use T3G\Analytics\Service\AnalyticsApiClient; use T3G\Analytics\Service\ApiExceptionExtractor; use T3G\Analytics\Service\ApiKeyService; use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; use TYPO3\CMS\Core\Http\RequestFactory; use TYPO3\CMS\Core\Settings\Settings; @@ -34,6 +37,7 @@ final class ApiKeyServiceTest extends UnitTestCase private array $httpHistory = []; private SiteSettingsService&MockObject $siteSettingsService; private SiteSettingsFactory&MockObject $siteSettingsFactory; + private SiteSettingsWriteVerifierInterface&MockObject $writeGuard; private CipherService $cipherService; private ApiKeyService $subject; @@ -56,6 +60,7 @@ protected function setUp(): void $this->siteSettingsService = $this->createMock(SiteSettingsService::class); $this->siteSettingsFactory = $this->createMock(SiteSettingsFactory::class); + $this->writeGuard = $this->createMock(SiteSettingsWriteVerifierInterface::class); $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['analytics']['apiBaseUrl'] = ''; $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['analytics']['verifySsl'] = '0'; @@ -71,6 +76,7 @@ protected function setUp(): void $this->cipherService, $this->siteSettingsService, $this->siteSettingsFactory, + $this->writeGuard, new NullLogger(), ); } @@ -197,6 +203,37 @@ public function provisionIfNeededDoesNotWriteWhenResponseIsIncomplete(): void $this->subject->provisionIfNeeded($site, ['status' => 'active']); } + #[Test] + public function provisionIfNeededReturnsWhenWriteSettingsThrows(): void + { + $this->mockHandler->append(new Response(200, [], '{"apiKeyId":"new-key-uuid","apiKey":"new-api-key"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->siteSettingsService + ->method('writeSettings') + ->willThrowException(new SiteConfigurationWriteException('disk full', 1590487411)); + + $site = $this->buildSite('w-123', 'i-456', $this->encryptedSecret); + $this->subject->provisionIfNeeded($site, ['status' => 'active']); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function provisionIfNeededLogsErrorWhenApiKeyCouldNotBePersisted(): void + { + $this->mockHandler->append(new Response(200, [], '{"apiKeyId":"new-key-uuid","apiKey":"new-api-key"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->writeGuard + ->method('assertSettingsPersisted') + ->willThrowException(new AnalyticsApiException('Credentials could not be written.', 0)); + + $site = $this->buildSite('w-123', 'i-456', $this->encryptedSecret); + $this->subject->provisionIfNeeded($site, ['status' => 'active']); + + // No exception thrown — the service returns silently and logs an error. + $this->expectNotToPerformAssertions(); + } + /** Helpers */ private function buildSite( diff --git a/Tests/Unit/Service/InstanceRegistrationServiceTest.php b/Tests/Unit/Service/InstanceRegistrationServiceTest.php index 803b589..91ca692 100644 --- a/Tests/Unit/Service/InstanceRegistrationServiceTest.php +++ b/Tests/Unit/Service/InstanceRegistrationServiceTest.php @@ -18,6 +18,8 @@ use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; use T3G\Analytics\Service\InstanceRegistrationService; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; +use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException; use TYPO3\CMS\Core\Http\Client\GuzzleClientFactory; use TYPO3\CMS\Core\Http\RequestFactory; use TYPO3\CMS\Core\Http\Uri; @@ -36,6 +38,7 @@ final class InstanceRegistrationServiceTest extends UnitTestCase private array $httpHistory = []; private SiteSettingsService&MockObject $siteSettingsService; private SiteSettingsFactory&MockObject $siteSettingsFactory; + private SiteSettingsWriteVerifierInterface&MockObject $writeGuard; private CipherService $cipherService; private InstanceRegistrationService $subject; @@ -54,6 +57,7 @@ protected function setUp(): void $this->siteSettingsService = $this->createMock(SiteSettingsService::class); $this->siteSettingsFactory = $this->createMock(SiteSettingsFactory::class); + $this->writeGuard = $this->createMock(SiteSettingsWriteVerifierInterface::class); $this->cipherService = new CipherService(); $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['analytics']['apiBaseUrl'] = ''; @@ -70,6 +74,7 @@ protected function setUp(): void $this->cipherService, $this->siteSettingsService, $this->siteSettingsFactory, + $this->writeGuard, new NullLogger(), ); } @@ -168,6 +173,49 @@ public function registerStoresEmptySecretWhenApiResponseOmitsIt(): void $this->subject->register($this->buildSite(), 'user@example.com'); } + #[Test] + public function registerThrowsBeforeApiCallWhenDirectoryIsNotWritable(): void + { + $this->writeGuard + ->method('assertDirectoryWritable') + ->willThrowException(new AnalyticsApiException('Cannot write to config/sites/main/.', 0)); + + $this->expectException(AnalyticsApiException::class); + $this->expectExceptionMessage('Cannot write to config/sites/main/.'); + + $this->subject->register($this->buildSite(), 'user@example.com'); + + self::assertCount(0, $this->httpHistory, 'API must not be called when directory is not writable.'); + } + + #[Test] + public function registerThrowsAnalyticsApiExceptionWhenWriteSettingsThrows(): void + { + $this->mockHandler->append(new Response(200, [], '{"websiteId":"w-123","instanceId":"i-456","instanceSecret":"s3cr3t"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->siteSettingsService + ->method('writeSettings') + ->willThrowException(new SiteConfigurationWriteException('disk full', 1590487411)); + + $this->expectException(AnalyticsApiException::class); + + $this->subject->register($this->buildSite(), 'user@example.com'); + } + + #[Test] + public function registerThrowsWhenSettingsCouldNotBePersisted(): void + { + $this->mockHandler->append(new Response(200, [], '{"websiteId":"w-123","instanceId":"i-456","instanceSecret":"s3cr3t"}')); + $this->siteSettingsFactory->method('loadLocalSettings')->willReturn([]); + $this->writeGuard + ->method('assertSettingsPersisted') + ->willThrowException(new AnalyticsApiException('Credentials could not be written.', 0)); + + $this->expectException(AnalyticsApiException::class); + + $this->subject->register($this->buildSite(), 'user@example.com'); + } + /** Helpers */ private function buildSite(): Site diff --git a/Tests/Unit/Service/SiteDataProviderTest.php b/Tests/Unit/Service/SiteDataProviderTest.php index 987ced9..c6116c9 100644 --- a/Tests/Unit/Service/SiteDataProviderTest.php +++ b/Tests/Unit/Service/SiteDataProviderTest.php @@ -19,6 +19,7 @@ use T3G\Analytics\Service\CipherService; use T3G\Analytics\Service\HmacSigner; use T3G\Analytics\Service\BackendPageAccessCheckerInterface; +use T3G\Analytics\Service\SiteSettingsWriteVerifierInterface; use T3G\Analytics\Service\SiteDataProvider; use TYPO3\CMS\Backend\Routing\UriBuilder; use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend; @@ -103,6 +104,7 @@ protected function setUp(): void new NullLogger(), $this->createMock(SiteSettingsService::class), $this->createMock(SiteSettingsFactory::class), + $this->createMock(SiteSettingsWriteVerifierInterface::class), ); $this->pageAccessChecker = $this->createMock(BackendPageAccessCheckerInterface::class);