0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-05-01 15:09:51 +00:00

Started aligning app-wide outbound http calling behaviour

This commit is contained in:
Dan Brown 2023-09-08 14:16:09 +01:00
parent 21cd2d17f6
commit a8b5652210
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
11 changed files with 159 additions and 107 deletions

View file

@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
{ {
use BearerAuthorizationTrait; use BearerAuthorizationTrait;
/** protected string $authorizationEndpoint;
* @var string protected string $tokenEndpoint;
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
/** /**
* Scopes to use for the OIDC authorization call. * Scopes to use for the OIDC authorization call.
@ -60,7 +53,7 @@ class OidcOAuthProvider extends AbstractProvider
} }
/** /**
* Add an additional scope to this provider upon the default. * Add another scope to this provider upon the default.
*/ */
public function addScope(string $scope): void public function addScope(string $scope): void
{ {

View file

@ -59,7 +59,7 @@ class OidcProviderSettings
} }
} }
if (strpos($this->issuer, 'https://') !== 0) { if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://'); throw new InvalidArgumentException('Issuer value must start with https://');
} }
} }

View file

@ -9,13 +9,13 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
/** /**
* Class OpenIdConnectService * Class OpenIdConnectService
@ -26,7 +26,7 @@ class OidcService
public function __construct( public function __construct(
protected RegistrationService $registrationService, protected RegistrationService $registrationService,
protected LoginService $loginService, protected LoginService $loginService,
protected HttpClient $httpClient, protected HttpRequestService $http,
protected GroupSyncService $groupService protected GroupSyncService $groupService
) { ) {
} }
@ -94,7 +94,7 @@ class OidcService
// Run discovery // Run discovery
if ($config['discover'] ?? false) { if ($config['discover'] ?? false) {
try { try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); $settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) { } catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
} }
@ -111,7 +111,7 @@ class OidcService
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{ {
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [ $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient, 'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(), 'optionProvider' => new HttpBasicAuthOptionProvider(),
]); ]);

View file

@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook; use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter; use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use BookStack\Util\SsrUrlValidator; use BookStack\Util\SsrUrlValidator;
@ -14,8 +15,8 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Psr\Http\Client\ClientExceptionInterface;
class DispatchWebhookJob implements ShouldQueue class DispatchWebhookJob implements ShouldQueue
{ {
@ -49,25 +50,28 @@ class DispatchWebhookJob implements ShouldQueue
* *
* @return void * @return void
*/ */
public function handle() public function handle(HttpRequestService $http)
{ {
$lastError = null; $lastError = null;
try { try {
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint); (new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
$response = Http::asJson() $client = $http->buildClient($this->webhook->timeout, [
->withOptions(['allow_redirects' => ['strict' => true]]) 'connect_timeout' => 10,
->timeout($this->webhook->timeout) 'allow_redirects' => ['strict' => true],
->post($this->webhook->endpoint, $this->webhookData); ]);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) { $response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
$lastError = "Response status from endpoint was {$response->status()}"; $statusCode = $response->getStatusCode();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
if ($statusCode >= 400) {
$lastError = "Response status from endpoint was {$statusCode}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
}
} catch (ClientExceptionInterface $error) {
$lastError = $error->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
} }
$this->webhook->last_called_at = now(); $this->webhook->last_called_at = now();

View file

@ -9,16 +9,15 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage; use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Http\HttpRequestService;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Util\CspService; use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\ExceptionRenderer; use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -39,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
SettingService::class => SettingService::class, SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class, SocialAuthService::class => SocialAuthService::class,
CspService::class => CspService::class, CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
]; ];
/** /**
@ -51,7 +51,7 @@ class AppServiceProvider extends ServiceProvider
// Set root URL // Set root URL
$appUrl = config('app.url'); $appUrl = config('app.url');
if ($appUrl) { if ($appUrl) {
$isHttps = (strpos($appUrl, 'https://') === 0); $isHttps = str_starts_with($appUrl, 'https://');
URL::forceRootUrl($appUrl); URL::forceRootUrl($appUrl);
URL::forceScheme($isHttps ? 'https' : 'http'); URL::forceScheme($isHttps ? 'https' : 'http');
} }
@ -75,12 +75,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
$this->app->bind(HttpClientInterface::class, function ($app) {
return new Client([
'timeout' => 3,
]);
});
$this->app->singleton(PermissionApplicator::class, function ($app) { $this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null); return new PermissionApplicator(null);
}); });

View file

@ -0,0 +1,28 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
class HttpClientHistory
{
public function __construct(
protected &$container
) {
}
public function requestCount(): int
{
return count($this->container);
}
public function requestAt(int $index): ?GuzzleRequest
{
return $this->container[$index]['request'] ?? null;
}
public function latestRequest(): ?GuzzleRequest
{
return $this->requestAt($this->requestCount() - 1);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use Psr\Http\Client\ClientInterface;
class HttpRequestService
{
protected ?HandlerStack $handler = null;
/**
* Build a new http client for sending requests on.
*/
public function buildClient(int $timeout, array $options): ClientInterface
{
$defaultOptions = [
'timeout' => $timeout,
'handler' => $this->handler,
];
return new Client(array_merge($options, $defaultOptions));
}
/**
* Create a new JSON http request for use with a client.
*/
public function jsonRequest(string $method, string $uri, array $data): GuzzleRequest
{
$headers = ['Content-Type' => 'application/json'];
return new GuzzleRequest($method, $uri, $headers, json_encode($data));
}
/**
* Mock any http clients built from this service, and response with the given responses.
* Returns history which can then be queried.
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
*/
public function mockClient(array $responses = []): HttpClientHistory
{
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler($responses);
$this->handler = HandlerStack::create($mock);
$this->handler->push($history, 'history');
return new HttpClientHistory($container);
}
/**
* Clear mocking that has been set up for clients.
*/
public function clearMocking(): void
{
$this->handler = null;
}
}

View file

@ -7,11 +7,10 @@ use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Webhook; use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\ActivityLogger; use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\Entities\Models\PageRevision;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Http\Client\Request; use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Tests\TestCase; use Tests\TestCase;
class WebhookCallTest extends TestCase class WebhookCallTest extends TestCase
@ -50,10 +49,10 @@ class WebhookCallTest extends TestCase
public function test_webhook_runs_for_delete_actions() public function test_webhook_runs_for_delete_actions()
{ {
// This test must not fake the queue/bus since this covers an issue
// around handling and serialization of items now deleted from the database.
$this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
Http::fake([ $this->mockHttpClient([new Response(500)]);
'*' => Http::response('', 500),
]);
$user = $this->users->newUser(); $user = $this->users->newUser();
$resp = $this->asAdmin()->delete($user->getEditUrl()); $resp = $this->asAdmin()->delete($user->getEditUrl());
@ -69,9 +68,7 @@ class WebhookCallTest extends TestCase
public function test_failed_webhook_call_logs_error() public function test_failed_webhook_call_logs_error()
{ {
$logger = $this->withTestLogger(); $logger = $this->withTestLogger();
Http::fake([ $this->mockHttpClient([new Response(500)]);
'*' => Http::response('', 500),
]);
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
$this->assertNull($webhook->last_errored_at); $this->assertNull($webhook->last_errored_at);
@ -86,7 +83,7 @@ class WebhookCallTest extends TestCase
public function test_webhook_call_exception_is_caught_and_logged() public function test_webhook_call_exception_is_caught_and_logged()
{ {
Http::shouldReceive('asJson')->andThrow(new \Exception('Failed to perform request')); $this->mockHttpClient([new ConnectException('Failed to perform request', new \GuzzleHttp\Psr7\Request('GET', ''))]);
$logger = $this->withTestLogger(); $logger = $this->withTestLogger();
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
@ -104,11 +101,11 @@ class WebhookCallTest extends TestCase
public function test_webhook_uses_ssr_hosts_option_if_set() public function test_webhook_uses_ssr_hosts_option_if_set()
{ {
config()->set('app.ssr_hosts', 'https://*.example.com'); config()->set('app.ssr_hosts', 'https://*.example.com');
$http = Http::fake(); $responses = $this->mockHttpClient();
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.co.uk'], ['all']); $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.co.uk'], ['all']);
$this->runEvent(ActivityType::ROLE_CREATE); $this->runEvent(ActivityType::ROLE_CREATE);
$http->assertNothingSent(); $this->assertEquals(0, $responses->requestCount());
$webhook->refresh(); $webhook->refresh();
$this->assertEquals('The URL does not match the configured allowed SSR hosts', $webhook->last_error); $this->assertEquals('The URL does not match the configured allowed SSR hosts', $webhook->last_error);
@ -117,29 +114,24 @@ class WebhookCallTest extends TestCase
public function test_webhook_call_data_format() public function test_webhook_call_data_format()
{ {
Http::fake([ $responses = $this->mockHttpClient([new Response(200, [], '')]);
'*' => Http::response('', 200),
]);
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
$page = $this->entities->page(); $page = $this->entities->page();
$editor = $this->users->editor(); $editor = $this->users->editor();
$this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor); $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
Http::assertSent(function (Request $request) use ($editor, $page, $webhook) { $request = $responses->latestRequest();
$reqData = $request->data(); $reqData = json_decode($request->getBody(), true);
$this->assertEquals('page_update', $reqData['event']);
return $request->isJson() $this->assertEquals(($editor->name . ' updated page "' . $page->name . '"'), $reqData['text']);
&& $reqData['event'] === 'page_update' $this->assertIsString($reqData['triggered_at']);
&& $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"') $this->assertEquals($editor->name, $reqData['triggered_by']['name']);
&& is_string($reqData['triggered_at']) $this->assertEquals($editor->getProfileUrl(), $reqData['triggered_by_profile_url']);
&& $reqData['triggered_by']['name'] === $editor->name $this->assertEquals($webhook->id, $reqData['webhook_id']);
&& $reqData['triggered_by_profile_url'] === $editor->getProfileUrl() $this->assertEquals($webhook->name, $reqData['webhook_name']);
&& $reqData['webhook_id'] === $webhook->id $this->assertEquals($page->getUrl(), $reqData['url']);
&& $reqData['webhook_name'] === $webhook->name $this->assertEquals($page->name, $reqData['related_item']['name']);
&& $reqData['url'] === $page->getUrl()
&& $reqData['related_item']['name'] === $page->name;
});
} }
protected function runEvent(string $event, $detail = '', ?User $user = null) protected function runEvent(string $event, $detail = '', ?User $user = null)

View file

@ -7,7 +7,6 @@ use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
use Tests\Helpers\OidcJwtHelper; use Tests\Helpers\OidcJwtHelper;
@ -137,7 +136,7 @@ class OidcTest extends TestCase
$this->post('/oidc/login'); $this->post('/oidc/login');
$state = session()->get('oidc_state'); $state = session()->get('oidc_state');
$transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([ $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
'email' => 'benny@example.com', 'email' => 'benny@example.com',
'sub' => 'benny1010101', 'sub' => 'benny1010101',
])]); ])]);
@ -146,9 +145,8 @@ class OidcTest extends TestCase
// App calls token endpoint to get id token // App calls token endpoint to get id token
$resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); $resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
$resp->assertRedirect('/'); $resp->assertRedirect('/');
$this->assertCount(1, $transactions); $this->assertEquals(1, $transactions->requestCount());
/** @var Request $tokenRequest */ $tokenRequest = $transactions->latestRequest();
$tokenRequest = $transactions[0]['request'];
$this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri()); $this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
$this->assertEquals('POST', $tokenRequest->getMethod()); $this->assertEquals('POST', $tokenRequest->getMethod());
$this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]); $this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
@ -279,7 +277,7 @@ class OidcTest extends TestCase
{ {
$this->withAutodiscovery(); $this->withAutodiscovery();
$transactions = &$this->mockHttpClient([ $transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(), $this->getAutoDiscoveryResponse(),
$this->getJwksResponse(), $this->getJwksResponse(),
]); ]);
@ -289,11 +287,9 @@ class OidcTest extends TestCase
$this->runLogin(); $this->runLogin();
$this->assertTrue(auth()->check()); $this->assertTrue(auth()->check());
/** @var Request $discoverRequest */
$discoverRequest = $transactions[0]['request'];
/** @var Request $discoverRequest */
$keysRequest = $transactions[1]['request'];
$discoverRequest = $transactions->requestAt(0);
$keysRequest = $transactions->requestAt(1);
$this->assertEquals('GET', $keysRequest->getMethod()); $this->assertEquals('GET', $keysRequest->getMethod());
$this->assertEquals('GET', $discoverRequest->getMethod()); $this->assertEquals('GET', $discoverRequest->getMethod());
$this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri()); $this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
@ -316,7 +312,7 @@ class OidcTest extends TestCase
{ {
$this->withAutodiscovery(); $this->withAutodiscovery();
$transactions = &$this->mockHttpClient([ $transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(), $this->getAutoDiscoveryResponse(),
$this->getJwksResponse(), $this->getJwksResponse(),
$this->getAutoDiscoveryResponse([ $this->getAutoDiscoveryResponse([
@ -327,15 +323,15 @@ class OidcTest extends TestCase
// Initial run // Initial run
$this->post('/oidc/login'); $this->post('/oidc/login');
$this->assertCount(2, $transactions); $this->assertEquals(2, $transactions->requestCount());
// Second run, hits cache // Second run, hits cache
$this->post('/oidc/login'); $this->post('/oidc/login');
$this->assertCount(2, $transactions); $this->assertEquals(2, $transactions->requestCount());
// Third run, different issuer, new cache key // Third run, different issuer, new cache key
config()->set(['oidc.issuer' => 'https://auto.example.com']); config()->set(['oidc.issuer' => 'https://auto.example.com']);
$this->post('/oidc/login'); $this->post('/oidc/login');
$this->assertCount(4, $transactions); $this->assertEquals(4, $transactions->requestCount());
} }
public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property() public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()

View file

@ -3,13 +3,11 @@
namespace Tests; namespace Tests;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Http\HttpClientHistory;
use BookStack\Http\HttpRequestService;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Uploads\HttpFetcher; use BookStack\Uploads\HttpFetcher;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Illuminate\Contracts\Console\Kernel; use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@ -21,7 +19,6 @@ use Illuminate\Testing\Assert as PHPUnit;
use Mockery; use Mockery;
use Monolog\Handler\TestHandler; use Monolog\Handler\TestHandler;
use Monolog\Logger; use Monolog\Logger;
use Psr\Http\Client\ClientInterface;
use Ssddanbrown\AssertHtml\TestsHtml; use Ssddanbrown\AssertHtml\TestsHtml;
use Tests\Helpers\EntityProvider; use Tests\Helpers\EntityProvider;
use Tests\Helpers\FileProvider; use Tests\Helpers\FileProvider;
@ -115,6 +112,7 @@ abstract class TestCase extends BaseTestCase
*/ */
protected function mockHttpFetch($returnData, int $times = 1) protected function mockHttpFetch($returnData, int $times = 1)
{ {
// TODO - Remove
$mockHttp = Mockery::mock(HttpFetcher::class); $mockHttp = Mockery::mock(HttpFetcher::class);
$this->app[HttpFetcher::class] = $mockHttp; $this->app[HttpFetcher::class] = $mockHttp;
$mockHttp->shouldReceive('fetch') $mockHttp->shouldReceive('fetch')
@ -123,21 +121,11 @@ abstract class TestCase extends BaseTestCase
} }
/** /**
* Mock the http client used in BookStack. * Mock the http client used in BookStack http calls.
* Returns a reference to the container which holds all history of http transactions.
*
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
*/ */
protected function &mockHttpClient(array $responses = []): array protected function mockHttpClient(array $responses = []): HttpClientHistory
{ {
$container = []; return $this->app->make(HttpRequestService::class)->mockClient($responses);
$history = Middleware::history($container);
$mock = new MockHandler($responses);
$handlerStack = new HandlerStack($mock);
$handlerStack->push($history);
$this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]);
return $container;
} }
/** /**

View file

@ -12,13 +12,10 @@ use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Http\Client\Request as HttpClientRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
class ThemeTest extends TestCase class ThemeTest extends TestCase
@ -177,9 +174,7 @@ class ThemeTest extends TestCase
}; };
Theme::listen(ThemeEvents::WEBHOOK_CALL_BEFORE, $callback); Theme::listen(ThemeEvents::WEBHOOK_CALL_BEFORE, $callback);
Http::fake([ $responses = $this->mockHttpClient([new \GuzzleHttp\Psr7\Response(200, [], '')]);
'*' => Http::response('', 200),
]);
$webhook = new Webhook(['name' => 'Test webhook', 'endpoint' => 'https://example.com']); $webhook = new Webhook(['name' => 'Test webhook', 'endpoint' => 'https://example.com']);
$webhook->save(); $webhook->save();
@ -193,9 +188,10 @@ class ThemeTest extends TestCase
$this->assertEquals($webhook->id, $args[1]->id); $this->assertEquals($webhook->id, $args[1]->id);
$this->assertEquals($detail->id, $args[2]->id); $this->assertEquals($detail->id, $args[2]->id);
Http::assertSent(function (HttpClientRequest $request) { $this->assertEquals(1, $responses->requestCount());
return $request->isJson() && $request->data()['test'] === 'hello!'; $request = $responses->latestRequest();
}); $reqData = json_decode($request->getBody(), true);
$this->assertEquals('hello!', $reqData['test']);
} }
public function test_event_activity_logged() public function test_event_activity_logged()