Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Classes/Service/AnalyticsStatusService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,7 @@ public function __construct(
private LoggerInterface $logger,
private SiteSettingsService $siteSettingsService,
private SiteSettingsFactory $siteSettingsFactory,
private SiteSettingsWriteVerifierInterface $writeGuard,
) {
}

Expand Down Expand Up @@ -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]
Expand Down
28 changes: 24 additions & 4 deletions Classes/Service/ApiKeyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@ public function __construct(
private CipherServiceInterface $cipherService,
private SiteSettingsService $siteSettingsService,
private SiteSettingsFactory $siteSettingsFactory,
private SiteSettingsWriteVerifierInterface $writeGuard,
private LoggerInterface $logger,
) {
}
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 21 additions & 5 deletions Classes/Service/InstanceRegistrationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ public function __construct(
private CipherServiceInterface $cipherService,
private SiteSettingsService $siteSettingsService,
private SiteSettingsFactory $siteSettingsFactory,
private SiteSettingsWriteVerifierInterface $writeGuard,
private LoggerInterface $logger,
) {
}
Expand All @@ -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) {
Expand All @@ -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]);
}
Expand Down
47 changes: 47 additions & 0 deletions Classes/Service/SiteSettingsWriteVerifier.php
Comment thread
buchmarv marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace T3G\Analytics\Service;

use T3G\Analytics\Exception\AnalyticsApiException;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteSettingsFactory;

final readonly class SiteSettingsWriteVerifier implements SiteSettingsWriteVerifierInterface
{
public function __construct(
private SiteSettingsFactory $siteSettingsFactory,
) {
}

public function assertDirectoryWritable(Site $site): void
{
$identifier = $site->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
);
}
}
}

}
27 changes: 27 additions & 0 deletions Classes/Service/SiteSettingsWriteVerifierInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace T3G\Analytics\Service;

use T3G\Analytics\Exception\AnalyticsApiException;
use TYPO3\CMS\Core\Site\Entity\Site;

interface SiteSettingsWriteVerifierInterface
{
/**
* Asserts that the site configuration directory is writable.
*
* @throws AnalyticsApiException
*/
public function assertDirectoryWritable(Site $site): void;

/**
* Asserts that the given settings were actually persisted to disk.
* Call this after writeSettings() to detect silent write failures.
*
* @param array<string, mixed> $expected key-value pairs that must appear in the persisted settings
* @throws AnalyticsApiException
*/
public function assertSettingsPersisted(Site $site, array $expected): void;
}
3 changes: 3 additions & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions Tests/Functional/Controller/BackendModuleControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -415,6 +416,7 @@ private function buildController(
new NullLogger(),
$this->createMock(SiteSettingsService::class),
$this->createMock(SiteSettingsFactory::class),
$this->createMock(SiteSettingsWriteVerifierInterface::class),
);

$siteDataProvider = new SiteDataProvider(
Expand All @@ -436,6 +438,7 @@ private function buildController(
$cipherService,
$this->createMock(SiteSettingsService::class),
$this->createMock(SiteSettingsFactory::class),
$this->createMock(SiteSettingsWriteVerifierInterface::class),
new NullLogger(),
);

Expand All @@ -444,6 +447,7 @@ private function buildController(
$cipherService,
$this->createMock(SiteSettingsService::class),
$this->createMock(SiteSettingsFactory::class),
$this->createMock(SiteSettingsWriteVerifierInterface::class),
new NullLogger(),
);

Expand Down
2 changes: 2 additions & 0 deletions Tests/Functional/Service/AnalyticsStatusServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -73,6 +74,7 @@ protected function setUp(): void
new \Psr\Log\NullLogger(),
$this->createMock(SiteSettingsService::class),
$this->createMock(SiteSettingsFactory::class),
$this->createMock(SiteSettingsWriteVerifierInterface::class),
);
}

Expand Down
2 changes: 2 additions & 0 deletions Tests/Functional/Service/InstanceRegistrationServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,6 +76,7 @@ protected function setUp(): void
$this->cipherService,
$this->siteSettingsService,
$this->siteSettingsFactory,
$this->createMock(SiteSettingsWriteVerifierInterface::class),
new \Psr\Log\NullLogger(),
);
}
Expand Down
96 changes: 96 additions & 0 deletions Tests/Functional/Service/SiteSettingsWriteVerifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace T3G\Analytics\Tests\Functional\Service;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use T3G\Analytics\Exception\AnalyticsApiException;
use T3G\Analytics\Service\SiteSettingsWriteVerifier;
use T3G\Analytics\Tests\Functional\Bootstrap\FunctionalTestCase;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Settings\Settings;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteSettings;
use TYPO3\CMS\Core\Site\SiteSettingsFactory;

final class SiteSettingsWriteVerifierTest extends FunctionalTestCase
{
private SiteSettingsFactory&MockObject $siteSettingsFactory;

protected function setUp(): void
{
parent::setUp();
$this->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;
}
}
Loading
Loading