mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-03 18:46:55 +09:00
* Stop Twig cache-directory write failures from flooding Sentry Twig's FilesystemCache throws a raw \RuntimeException when it can't create or write to the configured template cache directory (data/cache), which happens whenever a host's file permissions are wrong. That exception was completely uncaught, so every affected visitor got a raw 500 and every occurrence was reported to Sentry as a code bug even though it's a host misconfiguration we can't fix from within the app - this is the same class of noise fixed for the cache backend in #4275, just from a different code path (~60k events across 4 duplicate-shaped Sentry issues: FOSSBILLING-EBW/J8N/EWG/EWV). Box_App::convertCacheWriteFailure() now catches it in both Box_AppClient::render() and Box_AppAdmin::render(), converts it into a FOSSBilling\Exception with a new Cache-category error code (5002), and rethrows anything else unchanged. ErrorPage marks 5002 report:false, so Sentry stops seeing it while the visitor still gets a proper error page. * Handle all three Twig cache-write failure messages, not just one CodeRabbit review on #4277: Twig\Cache\FilesystemCache::write() throws three different \RuntimeException messages for the same underlying problem (missing/unwritable data/cache directory) - 'Unable to create the cache directory', 'Unable to write in the cache directory', and 'Failed to write cache file'. convertCacheWriteFailure() only matched the first, so the other two still fell through as raw, Sentry-reported fatals. Match all three, with a regression case for each. * Don't imply file permissions are the only cause of a cache write failure CodeRabbit review on #4276 (same code as here): 'Failed to write cache file' can also come from a full disk, a hit quota, or a read-only mount, not just a permissions problem - the error message told the admin to check permissions when that might not be the actual cause. Broadened the wording to also point at disk space.
222 lines
7.4 KiB
PHP
222 lines
7.4 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
|
|
/**
|
|
* Build a Box_AppClient that overrides render() with a caller-supplied
|
|
* callback, so the get_custom_page catch-block logic can be exercised
|
|
* without spinning up a Twig environment.
|
|
*/
|
|
function appClientWithRender(callable $render, bool $clientLoggedIn = false): Box_AppClient
|
|
{
|
|
$app = new class($render) extends Box_AppClient {
|
|
/** @var callable */
|
|
private $renderCallback;
|
|
|
|
public function __construct(callable $render)
|
|
{
|
|
parent::__construct();
|
|
$this->renderCallback = $render;
|
|
}
|
|
|
|
public function render($fileName, $variableArray = [], $ext = 'html.twig'): string
|
|
{
|
|
return ($this->renderCallback)($fileName, $variableArray, $ext);
|
|
}
|
|
};
|
|
|
|
$di = new Pimple\Container();
|
|
$di['logger'] = new class {
|
|
public function withChannel(string $channel): self
|
|
{
|
|
return $this;
|
|
}
|
|
|
|
public function error(string|Stringable $message, array $context = []): void
|
|
{
|
|
}
|
|
|
|
public function info(string|Stringable $message, array $context = []): void
|
|
{
|
|
}
|
|
};
|
|
$extensionService = new class {
|
|
public function isExtensionActive(): bool
|
|
{
|
|
return false;
|
|
}
|
|
};
|
|
$di['request'] = Request::create('http://localhost/test');
|
|
$di['mod_service'] = $di->protect(static fn (): object => $extensionService);
|
|
$di['auth'] = Mockery::mock(Box_Authorization::class)
|
|
->shouldReceive('isClientLoggedIn')->andReturn($clientLoggedIn)->getMock();
|
|
$di['url'] = Mockery::mock(FOSSBilling\Url::class)
|
|
->shouldReceive('link')->andReturnArg(0)->getMock();
|
|
$app->setDi($di);
|
|
$app->setUrl('/test');
|
|
|
|
return $app;
|
|
}
|
|
|
|
test('get_custom_page returns 200 on successful render', function (): void {
|
|
$app = appClientWithRender(fn (): string => '<html>ok</html>');
|
|
|
|
$response = $app->get_custom_page('signup');
|
|
|
|
expect($response->getStatusCode())->toBe(200);
|
|
expect($response->getContent())->toBe('<html>ok</html>');
|
|
});
|
|
|
|
test('get_custom_page returns XML content type for sitemap', function (): void {
|
|
$app = appClientWithRender(fn (): string => '<urlset></urlset>');
|
|
|
|
$response = $app->get_custom_page('sitemap.xml');
|
|
|
|
expect($response->getStatusCode())->toBe(200)
|
|
->and($response->headers->get('Content-Type'))->toContain('application/xml')
|
|
->and($response->getContent())->toBe('<urlset></urlset>');
|
|
});
|
|
|
|
test('get_custom_page redirects authenticated clients away from login', function (): void {
|
|
$app = appClientWithRender(
|
|
static fn (): never => throw new RuntimeException('The login page should not be rendered.'),
|
|
clientLoggedIn: true,
|
|
);
|
|
|
|
$response = $app->get_custom_page('login');
|
|
|
|
expect($response->getStatusCode())->toBe(302)
|
|
->and($response->headers->get('Location'))->toBe('/');
|
|
});
|
|
|
|
test('get_custom_page returns 500 (not 404) when the template throws a RuntimeError (regression for #3818)', function (): void {
|
|
$app = appClientWithRender(function (string $fileName): string {
|
|
if ($fileName === 'error') {
|
|
return 'error body';
|
|
}
|
|
|
|
throw new Twig\Error\RuntimeError('Variable "company" does not exist.');
|
|
});
|
|
|
|
$response = $app->get_custom_page('signup');
|
|
|
|
expect($response->getStatusCode())->toBe(500);
|
|
});
|
|
|
|
test('get_custom_page returns 500 (not 404) when the template throws a SyntaxError', function (): void {
|
|
$app = appClientWithRender(function (string $fileName): string {
|
|
if ($fileName === 'error') {
|
|
return 'error body';
|
|
}
|
|
|
|
throw new Twig\Error\SyntaxError('Unexpected token.');
|
|
});
|
|
|
|
$response = $app->get_custom_page('signup');
|
|
|
|
expect($response->getStatusCode())->toBe(500);
|
|
});
|
|
|
|
test('get_custom_page returns 500 (not 404) when a nested template is missing during render', function (): void {
|
|
$app = appClientWithRender(function (string $fileName): string {
|
|
if ($fileName === 'error') {
|
|
return 'error body';
|
|
}
|
|
|
|
throw new Twig\Error\LoaderError('Template "missing-partial.html.twig" is not defined.');
|
|
});
|
|
|
|
$response = $app->get_custom_page('signup');
|
|
|
|
expect($response->getStatusCode())->toBe(500);
|
|
});
|
|
|
|
test('get_custom_page still returns 404 when the top-level template is missing', function (): void {
|
|
$app = appClientWithRender(function (string $fileName): string {
|
|
if ($fileName === 'error') {
|
|
return 'error body';
|
|
}
|
|
|
|
throw new FOSSBilling\InformationException('Page not found', null, 404);
|
|
});
|
|
|
|
$response = $app->get_custom_page('signup');
|
|
|
|
expect($response->getStatusCode())->toBe(404);
|
|
});
|
|
|
|
test('render() converts a Twig cache write failure into a report:false exception (regression for FOSSBILLING-EBW)', function (string $message): void {
|
|
$app = new class extends Box_AppClient {
|
|
public function triggerCacheWriteFailure(RuntimeException $e): never
|
|
{
|
|
$this->convertCacheWriteFailure($e);
|
|
}
|
|
};
|
|
$di = new Pimple\Container();
|
|
$di['request'] = Request::create('http://localhost/test');
|
|
$di['logger'] = new class {
|
|
public function withChannel(string $channel): self
|
|
{
|
|
return $this;
|
|
}
|
|
|
|
public function error(string|Stringable $message, array $context = []): void
|
|
{
|
|
}
|
|
};
|
|
$app->setDi($di);
|
|
|
|
try {
|
|
$app->triggerCacheWriteFailure(new RuntimeException($message));
|
|
expect(false)->toBeTrue('Expected a FOSSBilling\Exception to be thrown.');
|
|
} catch (FOSSBilling\Exception $e) {
|
|
expect($e->getCode())->toBe(5002)
|
|
->and(FOSSBilling\ErrorPage::getCodeInfo($e->getCode())['report'])->toBeFalse();
|
|
}
|
|
})->with([
|
|
'directory does not exist and cannot be created' => ['Unable to create the cache directory (/var/www/data/cache/7d).'],
|
|
'directory exists but is not writable' => ['Unable to write in the cache directory (/var/www/data/cache/7d).'],
|
|
'cache file itself could not be written' => ['Failed to write cache file "/var/www/data/cache/7d/abc123.php".'],
|
|
]);
|
|
|
|
test('render() rethrows a RuntimeException unrelated to the Twig cache unchanged', function (): void {
|
|
$app = new class extends Box_AppClient {
|
|
public function triggerCacheWriteFailure(RuntimeException $e): never
|
|
{
|
|
$this->convertCacheWriteFailure($e);
|
|
}
|
|
};
|
|
$di = new Pimple\Container();
|
|
$di['request'] = Request::create('http://localhost/test');
|
|
$app->setDi($di);
|
|
|
|
expect(fn () => $app->triggerCacheWriteFailure(new RuntimeException('Something else entirely.')))
|
|
->toThrow(RuntimeException::class, 'Something else entirely.');
|
|
});
|
|
|
|
test('numeric custom page paths return a themed 404', function (): void {
|
|
$app = appClientWithRender(static function (string $fileName): string {
|
|
if ($fileName === 'error') {
|
|
return 'error body';
|
|
}
|
|
|
|
throw new FOSSBilling\InformationException('Page not found', null, 404);
|
|
});
|
|
$app->setUrl('/12345');
|
|
|
|
$response = $app->run();
|
|
|
|
expect($response->getStatusCode())->toBe(404)
|
|
->and($response->getContent())->toBe('error body');
|
|
});
|