Files
FOSSBilling/tests/Unit/Box/AppClientTest.php
Adam Daley de116893b6 Harden URL, mail, and locale handling (#4359)
* Harden URL, mail, and locale handling

* Keep Sentry hardening mail test offline with unsupported DSN scheme

* Move Sentry regression tests into their module test files

* Drop Sentry issue IDs from regression test names

* Drop Sentry issue ID from Twig cache failure test name

* Drop Sentry issue IDs from test comments
2026-09-19 13:49:38 +01:00

236 lines
7.7 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', 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('setUrl normalizes null and empty string to /', function (): void {
$app = new Box_AppClient();
$url = new ReflectionProperty(Box_App::class, 'url');
$app->setUrl(null);
expect($url->getValue($app))->toBe('/');
$app->setUrl('');
expect($url->getValue($app))->toBe('/');
$app->setUrl('//.env');
expect($url->getValue($app))->toBe('//.env');
});
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');
});