5 use BookStack\Activity\ActivityType;
6 use BookStack\Activity\DispatchWebhookJob;
7 use BookStack\Activity\Models\Webhook;
8 use BookStack\Entities\Models\Book;
9 use BookStack\Entities\Models\Page;
10 use BookStack\Entities\Tools\PageContent;
11 use BookStack\Exceptions\ThemeException;
12 use BookStack\Facades\Theme;
13 use BookStack\Theming\ThemeEvents;
14 use BookStack\Users\Models\User;
15 use Illuminate\Console\Command;
16 use Illuminate\Http\Request;
17 use Illuminate\Http\Response;
18 use Illuminate\Support\Facades\Artisan;
19 use Illuminate\Support\Facades\File;
20 use League\CommonMark\Environment\Environment;
22 class ThemeTest extends TestCase
24 protected string $themeFolderName;
25 protected string $themeFolderPath;
27 public function test_translation_text_can_be_overridden_via_theme()
29 $this->usingThemeFolder(function () {
30 $translationPath = theme_path('/lang/en');
31 File::makeDirectory($translationPath, 0777, true);
33 $customTranslations = '<?php
34 return [\'books\' => \'Sandwiches\'];
36 file_put_contents($translationPath . '/entities.php', $customTranslations);
38 $homeRequest = $this->actingAs($this->users->viewer())->get('/');
39 $this->withHtml($homeRequest)->assertElementContains('header nav', 'Sandwiches');
43 public function test_theme_functions_file_used_and_app_boot_event_runs()
45 $this->usingThemeFolder(function ($themeFolder) {
46 $functionsFile = theme_path('functions.php');
47 app()->alias('cat', 'dog');
48 file_put_contents($functionsFile, "<?php\nTheme::listen(\BookStack\Theming\ThemeEvents::APP_BOOT, function(\$app) { \$app->alias('cat', 'dog');});");
49 $this->runWithEnv('APP_THEME', $themeFolder, function () {
50 $this->assertEquals('cat', $this->app->getAlias('dog'));
55 public function test_theme_functions_loads_errors_are_caught_and_logged()
57 $this->usingThemeFolder(function ($themeFolder) {
58 $functionsFile = theme_path('functions.php');
59 file_put_contents($functionsFile, "<?php\n\\BookStack\\Biscuits::eat();");
61 $this->expectException(ThemeException::class);
62 $this->expectExceptionMessageMatches('/Failed loading theme functions file at ".*?" with error: Class "BookStack\\\\Biscuits" not found/');
64 $this->runWithEnv('APP_THEME', $themeFolder, fn() => null);
68 public function test_event_commonmark_environment_configure()
70 $callbackCalled = false;
71 $callback = function ($environment) use (&$callbackCalled) {
72 $this->assertInstanceOf(Environment::class, $environment);
73 $callbackCalled = true;
77 Theme::listen(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $callback);
79 $page = $this->entities->page();
80 $content = new PageContent($page);
81 $content->setNewMarkdown('# test', $this->users->editor());
83 $this->assertTrue($callbackCalled);
86 public function test_event_web_middleware_before()
88 $callbackCalled = false;
90 $callback = function ($request) use (&$callbackCalled, &$requestParam) {
91 $requestParam = $request;
92 $callbackCalled = true;
95 Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $callback);
96 $this->get('/login', ['Donkey' => 'cat']);
98 $this->assertTrue($callbackCalled);
99 $this->assertInstanceOf(Request::class, $requestParam);
100 $this->assertEquals('cat', $requestParam->header('donkey'));
103 public function test_event_web_middleware_before_return_val_used_as_response()
105 $callback = function (Request $request) {
106 return response('cat', 412);
109 Theme::listen(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $callback);
110 $resp = $this->get('/login', ['Donkey' => 'cat']);
111 $resp->assertSee('cat');
112 $resp->assertStatus(412);
115 public function test_event_web_middleware_after()
117 $callbackCalled = false;
118 $requestParam = null;
119 $responseParam = null;
120 $callback = function ($request, Response $response) use (&$callbackCalled, &$requestParam, &$responseParam) {
121 $requestParam = $request;
122 $responseParam = $response;
123 $callbackCalled = true;
124 $response->header('donkey', 'cat123');
127 Theme::listen(ThemeEvents::WEB_MIDDLEWARE_AFTER, $callback);
129 $resp = $this->get('/login', ['Donkey' => 'cat']);
130 $this->assertTrue($callbackCalled);
131 $this->assertInstanceOf(Request::class, $requestParam);
132 $this->assertInstanceOf(Response::class, $responseParam);
133 $resp->assertHeader('donkey', 'cat123');
136 public function test_event_web_middleware_after_return_val_used_as_response()
138 $callback = function () {
139 return response('cat456', 443);
142 Theme::listen(ThemeEvents::WEB_MIDDLEWARE_AFTER, $callback);
144 $resp = $this->get('/login', ['Donkey' => 'cat']);
145 $resp->assertSee('cat456');
146 $resp->assertStatus(443);
149 public function test_event_auth_login_standard()
152 $callback = function (...$eventArgs) use (&$args) {
156 Theme::listen(ThemeEvents::AUTH_LOGIN, $callback);
157 $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']);
159 $this->assertCount(2, $args);
160 $this->assertEquals('standard', $args[0]);
161 $this->assertInstanceOf(User::class, $args[1]);
164 public function test_event_auth_register_standard()
167 $callback = function (...$eventArgs) use (&$args) {
170 Theme::listen(ThemeEvents::AUTH_REGISTER, $callback);
171 $this->setSettings(['registration-enabled' => 'true']);
173 $user = User::factory()->make();
174 $this->post('/register', ['email' => $user->email, 'name' => $user->name, 'password' => 'password']);
176 $this->assertCount(2, $args);
177 $this->assertEquals('standard', $args[0]);
178 $this->assertInstanceOf(User::class, $args[1]);
181 public function test_event_auth_pre_register()
184 $callback = function (...$eventArgs) use (&$args) {
187 Theme::listen(ThemeEvents::AUTH_PRE_REGISTER, $callback);
188 $this->setSettings(['registration-enabled' => 'true']);
190 $user = User::factory()->make();
191 $this->post('/register', ['email' => $user->email, 'name' => $user->name, 'password' => 'password']);
193 $this->assertCount(2, $args);
194 $this->assertEquals('standard', $args[0]);
195 $this->assertEquals([
196 'email' => $user->email,
197 'name' => $user->name,
198 'password' => 'password',
200 $this->assertDatabaseHas('users', ['email' => $user->email]);
203 public function test_event_auth_pre_register_with_false_return_blocks_registration()
205 $callback = function () {
208 Theme::listen(ThemeEvents::AUTH_PRE_REGISTER, $callback);
209 $this->setSettings(['registration-enabled' => 'true']);
211 $user = User::factory()->make();
212 $resp = $this->post('/register', ['email' => $user->email, 'name' => $user->name, 'password' => 'password']);
213 $resp->assertRedirect('/login');
214 $this->assertSessionError('User account could not be registered for the provided details');
215 $this->assertDatabaseMissing('users', ['email' => $user->email]);
218 public function test_event_webhook_call_before()
221 $callback = function (...$eventArgs) use (&$args) {
224 return ['test' => 'hello!'];
226 Theme::listen(ThemeEvents::WEBHOOK_CALL_BEFORE, $callback);
228 $responses = $this->mockHttpClient([new \GuzzleHttp\Psr7\Response(200, [], '')]);
230 $webhook = new Webhook(['name' => 'Test webhook', 'endpoint' => 'https://example.com']);
232 $event = ActivityType::PAGE_UPDATE;
233 $detail = Page::query()->first();
235 dispatch((new DispatchWebhookJob($webhook, $event, $detail)));
237 $this->assertCount(5, $args);
238 $this->assertEquals($event, $args[0]);
239 $this->assertEquals($webhook->id, $args[1]->id);
240 $this->assertEquals($detail->id, $args[2]->id);
242 $this->assertEquals(1, $responses->requestCount());
243 $request = $responses->latestRequest();
244 $reqData = json_decode($request->getBody(), true);
245 $this->assertEquals('hello!', $reqData['test']);
248 public function test_event_activity_logged()
250 $book = $this->entities->book();
252 $callback = function (...$eventArgs) use (&$args) {
256 Theme::listen(ThemeEvents::ACTIVITY_LOGGED, $callback);
257 $this->asEditor()->put($book->getUrl(), ['name' => 'My cool update book!']);
259 $this->assertCount(2, $args);
260 $this->assertEquals(ActivityType::BOOK_UPDATE, $args[0]);
261 $this->assertTrue($args[1] instanceof Book);
262 $this->assertEquals($book->id, $args[1]->id);
265 public function test_event_page_include_parse()
267 /** @var Page $page */
268 /** @var Page $otherPage */
269 $page = $this->entities->page();
270 $otherPage = Page::query()->where('id', '!=', $page->id)->first();
271 $otherPage->html = '<p id="bkmrk-cool">This is a really cool section</p>';
272 $page->html = "<p>{{@{$otherPage->id}#bkmrk-cool}}</p>";
277 $callback = function (...$eventArgs) use (&$args) {
280 return '<strong>Big & content replace surprise!</strong>';
283 Theme::listen(ThemeEvents::PAGE_INCLUDE_PARSE, $callback);
284 $resp = $this->asEditor()->get($page->getUrl());
285 $this->withHtml($resp)->assertElementContains('.page-content strong', 'Big & content replace surprise!');
287 $this->assertCount(4, $args);
288 $this->assertEquals($otherPage->id . '#bkmrk-cool', $args[0]);
289 $this->assertEquals('This is a really cool section', $args[1]);
290 $this->assertTrue($args[2] instanceof Page);
291 $this->assertTrue($args[3] instanceof Page);
292 $this->assertEquals($page->id, $args[2]->id);
293 $this->assertEquals($otherPage->id, $args[3]->id);
296 public function test_event_routes_register_web_and_web_auth()
298 $functionsContent = <<<'END'
300 use BookStack\Theming\ThemeEvents;
301 use BookStack\Facades\Theme;
302 use Illuminate\Routing\Router;
303 Theme::listen(ThemeEvents::ROUTES_REGISTER_WEB, function (Router $router) {
304 $router->get('/cat', fn () => 'cat')->name('say.cat');
306 Theme::listen(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, function (Router $router) {
307 $router->get('/dog', fn () => 'dog')->name('say.dog');
311 $this->usingThemeFolder(function () use ($functionsContent) {
313 $functionsFile = theme_path('functions.php');
314 file_put_contents($functionsFile, $functionsContent);
316 $app = $this->createApplication();
317 /** @var \Illuminate\Routing\Router $router */
318 $router = $app->get('router');
320 /** @var \Illuminate\Routing\Route $catRoute */
321 $catRoute = $router->getRoutes()->getRoutesByName()['say.cat'];
322 $this->assertEquals(['web'], $catRoute->middleware());
324 /** @var \Illuminate\Routing\Route $dogRoute */
325 $dogRoute = $router->getRoutes()->getRoutesByName()['say.dog'];
326 $this->assertEquals(['web', 'auth'], $dogRoute->middleware());
330 public function test_add_social_driver()
332 Theme::addSocialDriver('catnet', [
333 'client_id' => 'abc123',
334 'client_secret' => 'def456',
335 ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
337 $this->assertEquals('catnet', config('services.catnet.name'));
338 $this->assertEquals('abc123', config('services.catnet.client_id'));
339 $this->assertEquals(url('/login/service/catnet/callback'), config('services.catnet.redirect'));
341 $loginResp = $this->get('/login');
342 $loginResp->assertSee('login/service/catnet');
345 public function test_add_social_driver_uses_name_in_config_if_given()
347 Theme::addSocialDriver('catnet', [
348 'client_id' => 'abc123',
349 'client_secret' => 'def456',
350 'name' => 'Super Cat Name',
351 ], 'SocialiteProviders\Discord\DiscordExtendSocialite@handleTesting');
353 $this->assertEquals('Super Cat Name', config('services.catnet.name'));
354 $loginResp = $this->get('/login');
355 $loginResp->assertSee('Super Cat Name');
358 public function test_add_social_driver_allows_a_configure_for_redirect_callback_to_be_passed()
360 Theme::addSocialDriver(
363 'client_id' => 'abc123',
364 'client_secret' => 'def456',
365 'name' => 'Super Cat Name',
367 'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
369 $driver->with(['donkey' => 'donut']);
373 $loginResp = $this->get('/login/service/discord');
374 $redirect = $loginResp->headers->get('location');
375 $this->assertStringContainsString('donkey=donut', $redirect);
378 public function test_register_command_allows_provided_command_to_be_usable_via_artisan()
380 Theme::registerCommand(new MyCustomCommand());
382 Artisan::call('bookstack:test-custom-command', []);
383 $output = Artisan::output();
385 $this->assertStringContainsString('Command ran!', $output);
388 public function test_base_body_start_and_end_template_files_can_be_used()
390 $bodyStartStr = 'barry-fought-against-the-panther';
391 $bodyEndStr = 'barry-lost-his-fight-with-grace';
393 $this->usingThemeFolder(function (string $folder) use ($bodyStartStr, $bodyEndStr) {
394 $viewDir = theme_path('layouts/parts');
395 mkdir($viewDir, 0777, true);
396 file_put_contents($viewDir . '/base-body-start.blade.php', $bodyStartStr);
397 file_put_contents($viewDir . '/base-body-end.blade.php', $bodyEndStr);
399 $resp = $this->asEditor()->get('/');
400 $resp->assertSee($bodyStartStr);
401 $resp->assertSee($bodyEndStr);
405 public function test_export_body_start_and_end_template_files_can_be_used()
407 $bodyStartStr = 'garry-fought-against-the-panther';
408 $bodyEndStr = 'garry-lost-his-fight-with-grace';
409 $page = $this->entities->page();
411 $this->usingThemeFolder(function (string $folder) use ($bodyStartStr, $bodyEndStr, $page) {
412 $viewDir = theme_path('layouts/parts');
413 mkdir($viewDir, 0777, true);
414 file_put_contents($viewDir . '/export-body-start.blade.php', $bodyStartStr);
415 file_put_contents($viewDir . '/export-body-end.blade.php', $bodyEndStr);
417 $resp = $this->asEditor()->get($page->getUrl('/export/html'));
418 $resp->assertSee($bodyStartStr);
419 $resp->assertSee($bodyEndStr);
423 public function test_login_and_register_message_template_files_can_be_used()
425 $loginMessage = 'Welcome to this instance, login below you scallywag';
426 $registerMessage = 'You want to register? Enter the deets below you numpty';
428 $this->usingThemeFolder(function (string $folder) use ($loginMessage, $registerMessage) {
429 $viewDir = theme_path('auth/parts');
430 mkdir($viewDir, 0777, true);
431 file_put_contents($viewDir . '/login-message.blade.php', $loginMessage);
432 file_put_contents($viewDir . '/register-message.blade.php', $registerMessage);
433 $this->setSettings(['registration-enabled' => 'true']);
435 $this->get('/login')->assertSee($loginMessage);
436 $this->get('/register')->assertSee($registerMessage);
440 public function test_header_links_start_template_file_can_be_used()
442 $content = 'This is added text in the header bar';
444 $this->usingThemeFolder(function (string $folder) use ($content) {
445 $viewDir = theme_path('layouts/parts');
446 mkdir($viewDir, 0777, true);
447 file_put_contents($viewDir . '/header-links-start.blade.php', $content);
448 $this->setSettings(['registration-enabled' => 'true']);
450 $this->get('/login')->assertSee($content);
454 public function test_custom_settings_category_page_can_be_added_via_view_file()
456 $content = 'My SuperCustomSettings';
458 $this->usingThemeFolder(function (string $folder) use ($content) {
459 $viewDir = theme_path('settings/categories');
460 mkdir($viewDir, 0777, true);
461 file_put_contents($viewDir . '/beans.blade.php', $content);
463 $this->asAdmin()->get('/settings/beans')->assertSee($content);
467 public function test_public_folder_contents_accessible_via_route()
469 $this->usingThemeFolder(function (string $themeFolderName) {
470 $publicDir = theme_path('public');
471 mkdir($publicDir, 0777, true);
473 $text = 'some-text ' . md5(random_bytes(5));
474 $css = "body { background-color: tomato !important; }";
475 file_put_contents("{$publicDir}/file.txt", $text);
476 file_put_contents("{$publicDir}/file.css", $css);
477 copy($this->files->testFilePath('test-image.png'), "{$publicDir}/image.png");
479 $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.txt");
480 $resp->assertStreamedContent($text);
481 $resp->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
482 $resp->assertHeader('Cache-Control', 'max-age=86400, private');
484 $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/image.png");
485 $resp->assertHeader('Content-Type', 'image/png');
486 $resp->assertHeader('Cache-Control', 'max-age=86400, private');
488 $resp = $this->asAdmin()->get("/theme/{$themeFolderName}/file.css");
489 $resp->assertStreamedContent($css);
490 $resp->assertHeader('Content-Type', 'text/css; charset=UTF-8');
491 $resp->assertHeader('Cache-Control', 'max-age=86400, private');
495 protected function usingThemeFolder(callable $callback)
497 // Create a folder and configure a theme
498 $themeFolderName = 'testing_theme_' . str_shuffle(rtrim(base64_encode(time()), '='));
499 config()->set('view.theme', $themeFolderName);
500 $themeFolderPath = theme_path('');
502 // Create theme folder and clean it up on application tear-down
503 File::makeDirectory($themeFolderPath);
504 $this->beforeApplicationDestroyed(fn() => File::deleteDirectory($themeFolderPath));
506 // Run provided callback with theme env option set
507 $this->runWithEnv('APP_THEME', $themeFolderName, function () use ($callback, $themeFolderName) {
508 call_user_func($callback, $themeFolderName);
513 class MyCustomCommand extends Command
515 protected $signature = 'bookstack:test-custom-command';
517 public function handle()
519 $this->line('Command ran!');