2026-02-05 21:57:12 +00:00
< ? php
namespace Tests\Commands ;
use GuzzleHttp\Psr7\Response ;
use Illuminate\Support\Facades\File ;
use Tests\TestCase ;
use ZipArchive ;
class InstallModuleCommandTest extends TestCase
{
public function test_local_module_install_with_active_theme ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
$expectedInstallPath = theme_path ( 'modules/test-module' );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsOutput ( " \n This will install a module from: { $zip } \n \n Modules can contain code which would have the ability to do anything on the BookStack host server. \n You should only install modules from trusted sources. " )
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( 'Module "Test Module" (v1.0.0) successfully installed!' )
-> expectsOutput ( " Install location: { $expectedInstallPath } " )
-> assertExitCode ( 0 );
$this -> assertDirectoryExists ( $expectedInstallPath );
$this -> assertFileExists ( $expectedInstallPath . '/bookstack-module.json' );
});
}
public function test_remote_module_install_with_active_theme ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
$http = $this -> mockHttpClient ([
new Response ( 200 , [ 'Content-Length' => filesize ( $zip )], file_get_contents ( $zip ))
]);
$expectedInstallPath = theme_path ( 'modules/test-module' );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => 'https://example.com/test-module.zip' ])
2026-03-06 09:28:46 +00:00
-> expectsOutput ( " \n This will download a module from: example.com \n \n Modules can contain code which would have the ability to do anything on the BookStack host server. \n You should only install modules from trusted sources. " )
2026-02-05 21:57:12 +00:00
-> expectsConfirmation ( 'Are you sure you trust this source?' , 'yes' )
-> expectsOutput ( 'Module "Test Module" (v1.0.0) successfully installed!' )
-> expectsOutput ( " Install location: { $expectedInstallPath } " )
-> assertExitCode ( 0 );
$this -> assertEquals ( 1 , $http -> requestCount ());
$request = $http -> requestAt ( 0 );
$this -> assertEquals ( '/test-module.zip' , $request -> getUri () -> getPath ());
$this -> assertDirectoryExists ( $expectedInstallPath );
$this -> assertFileExists ( $expectedInstallPath . '/bookstack-module.json' );
});
}
public function test_remote_http_module_warns_and_prompts_users ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
$http = $this -> mockHttpClient ([
new Response ( 200 , [ 'Content-Length' => filesize ( $zip )], file_get_contents ( $zip ))
]);
$expectedInstallPath = theme_path ( 'modules/test-module' );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => 'http://example.com/test-module.zip' ])
2026-03-06 09:28:46 +00:00
-> expectsOutput ( " \n This will download a module from: example.com \n \n Modules can contain code which would have the ability to do anything on the BookStack host server. \n You should only install modules from trusted sources. " )
2026-02-05 21:57:12 +00:00
-> expectsConfirmation ( 'Are you sure you trust this source?' , 'yes' )
-> expectsOutput ( " You are downloading a module from an insecure HTTP source. \n We recommend only using HTTPS sources to avoid various security risks. " )
-> expectsConfirmation ( 'Are you sure you want to continue without HTTPS?' , 'yes' )
-> expectsOutput ( 'Module "Test Module" (v1.0.0) successfully installed!' )
-> expectsOutput ( " Install location: { $expectedInstallPath } " )
-> assertExitCode ( 0 );
$request = $http -> requestAt ( 0 );
$this -> assertEquals ( '/test-module.zip' , $request -> getUri () -> getPath ());
});
}
public function test_remote_module_install_follows_redirects ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
$http = $this -> mockHttpClient ([
new Response ( 302 , [ 'Location' => 'https://example.com/a-test-module.zip' ]),
new Response ( 200 , [ 'Content-Length' => filesize ( $zip )], file_get_contents ( $zip ))
]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => 'https://example.com/test-module.zip' ])
-> expectsConfirmation ( 'Are you sure you trust this source?' , 'yes' )
-> assertExitCode ( 0 );
$this -> assertEquals ( 2 , $http -> requestCount ());
$this -> assertEquals ( '/test-module.zip' , $http -> requestAt ( 0 ) -> getUri () -> getPath ());
$this -> assertEquals ( '/a-test-module.zip' , $http -> requestAt ( 1 ) -> getUri () -> getPath ());
});
}
public function test_remote_module_install_does_not_follow_redirects_to_different_origin ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
$http = $this -> mockHttpClient ([
new Response ( 302 , [ 'Location' => 'http://example.com/a-test-module.zip' ]),
new Response ( 200 , [ 'Content-Length' => filesize ( $zip )], file_get_contents ( $zip ))
]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => 'https://example.com/test-module.zip' ])
-> expectsConfirmation ( 'Are you sure you trust this source?' , 'yes' )
-> assertExitCode ( 1 );
$this -> assertEquals ( 1 , $http -> requestCount ());
$this -> assertEquals ( 'https' , $http -> requestAt ( 0 ) -> getUri () -> getScheme ());
});
}
public function test_remote_module_install_download_failures_are_announced_to_user ()
{
$this -> usingThemeFolder ( function () {
$http = $this -> mockHttpClient ([
new Response ( 404 ),
]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => 'https://example.com/test-module.zip' ])
-> expectsConfirmation ( 'Are you sure you trust this source?' , 'yes' )
-> expectsOutput ( 'ERROR: Failed to download module from https://example.com/test-module.zip' )
-> expectsOutput ( 'Download failed with status code 404' )
-> assertExitCode ( 1 );
$this -> assertEquals ( 1 , $http -> requestCount ());
});
}
public function test_run_with_invalid_path_exits_early ()
{
$this -> artisan ( 'bookstack:install-module' , [ 'location' => '/not-found.zip' ])
-> expectsOutput ( 'ERROR: Module file not found at /not-found.zip' )
-> assertExitCode ( 1 );
}
public function test_run_with_invalid_zip_has_early_exit ()
{
$zip = $this -> getModuleZipPath ();
file_put_contents ( $zip , 'invalid zip' );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( " ERROR: Cannot open ZIP file at { $zip } " )
-> assertExitCode ( 1 );
}
public function test_run_with_large_zip_has_early_exit ()
{
$zip = $this -> getModuleZipPath ( null , [
'large-file.txt' => str_repeat ( 'a' , 1024 * 1024 * 51 )
]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( " ERROR: Module ZIP file contents are too large. Maximum size is 50MB " )
-> assertExitCode ( 1 );
}
public function test_run_with_invalid_module_data_has_early_exit ()
{
$zip = $this -> getModuleZipPath ([
'name' => 'Invalid Module' ,
'description' => 'A module with invalid data' ,
'version' => 'dog' ,
]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( " ERROR: Failed to read module metadata with error: Module in folder \" _temp \" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0' " )
-> assertExitCode ( 1 );
}
public function test_local_module_install_without_active_theme_can_setup_theme_folder ()
{
$zip = $this -> getModuleZipPath ();
$expectedThemePath = base_path ( 'themes/custom' );
File :: deleteDirectory ( $expectedThemePath );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsConfirmation ( 'No active theme folder found, would you like to create one?' , 'yes' )
-> expectsOutput ( " Created theme folder at { $expectedThemePath } " )
-> expectsOutput ( " You will need to set APP_THEME=custom in your BookStack env configuration to enable this theme! " )
-> expectsOutput ( 'Module "Test Module" (v1.0.0) successfully installed!' )
-> assertExitCode ( 0 );
$this -> assertDirectoryExists ( $expectedThemePath . '/modules/test-module' );
File :: deleteDirectory ( $expectedThemePath );
}
public function test_local_module_install_with_active_theme_and_conflicting_modules_file_causes_early_exit ()
{
$this -> usingThemeFolder ( function () {
$zip = $this -> getModuleZipPath ();
File :: put ( theme_path ( 'modules' ), '{}' );
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $zip ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( " ERROR: Cannot create a modules folder, file already exists at " . theme_path ( 'modules' ))
-> assertExitCode ( 1 );
});
}
public function test_single_existing_module_with_same_name_replace ()
{
$this -> usingThemeFolder ( function () {
$original = $this -> createModuleFolderInCurrentTheme ([ 'name' => 'Test Module' , 'description' => 'cat' , 'version' => '1.0.0' ]);
$new = $this -> getModuleZipPath ([ 'name' => 'Test Module' , 'description' => '' , 'version' => '2.0.0' ]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $new ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( 'The following modules already exist with the same name:' )
-> expectsOutput ( 'Test Module (test-module:v1.0.0) - cat' )
-> expectsChoice ( 'What would you like to do?' , 'Replace existing module' , [ 'Cancel module install' , 'Add alongside existing module' , 'Replace existing module' ])
-> expectsOutput ( " Replacing existing module in test-module folder " )
-> assertExitCode ( 0 );
$this -> assertFileExists ( $original . '/bookstack-module.json' );
$metadata = json_decode ( file_get_contents ( $original . '/bookstack-module.json' ), true );
$this -> assertEquals ( '2.0.0' , $metadata [ 'version' ]);
});
}
public function test_single_existing_module_with_same_name_cancel ()
{
$this -> usingThemeFolder ( function () {
$original = $this -> createModuleFolderInCurrentTheme ([ 'name' => 'Test Module' , 'description' => 'cat' , 'version' => '1.0.0' ]);
$new = $this -> getModuleZipPath ([ 'name' => 'Test Module' , 'description' => '' , 'version' => '2.0.0' ]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $new ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( 'The following modules already exist with the same name:' )
-> expectsOutput ( 'Test Module (test-module:v1.0.0) - cat' )
-> expectsChoice ( 'What would you like to do?' , 'Cancel module install' , [ 'Cancel module install' , 'Add alongside existing module' , 'Replace existing module' ])
-> assertExitCode ( 1 );
$this -> assertFileExists ( $original . '/bookstack-module.json' );
$metadata = json_decode ( file_get_contents ( $original . '/bookstack-module.json' ), true );
$this -> assertEquals ( '1.0.0' , $metadata [ 'version' ]);
});
}
public function test_single_existing_module_with_same_name_add ()
{
$this -> usingThemeFolder ( function () {
$original = $this -> createModuleFolderInCurrentTheme ([ 'name' => 'Test Module' , 'description' => 'cat' , 'version' => '1.0.0' ]);
$new = $this -> getModuleZipPath ([ 'name' => 'Test Module' , 'description' => '' , 'version' => '2.0.0' ]);
$this -> artisan ( 'bookstack:install-module' , [ 'location' => $new ])
2026-03-06 09:28:46 +00:00
-> expectsConfirmation ( 'Are you sure you want to install this module?' , 'yes' )
2026-02-05 21:57:12 +00:00
-> expectsOutput ( 'The following modules already exist with the same name:' )
-> expectsOutput ( 'Test Module (test-module:v1.0.0) - cat' )
-> expectsChoice ( 'What would you like to do?' , 'Add alongside existing module' , [ 'Cancel module install' , 'Add alongside existing module' , 'Replace existing module' ])
-> assertExitCode ( 0 );
$dirs = File :: directories ( theme_path ( 'modules/' ));
$this -> assertCount ( 2 , $dirs );
});
}
protected function createModuleFolderInCurrentTheme ( array | null $metadata = null , array $extraFiles = []) : string
{
$original = $this -> getModuleZipPath ( $metadata , $extraFiles );
$targetPath = theme_path ( 'modules/test-module' );
mkdir ( $targetPath , 0777 , true );
$originalZip = new ZipArchive ();
$originalZip -> open ( $original );
$originalZip -> extractTo ( $targetPath );
$originalZip -> close ();
return $targetPath ;
}
protected function getModuleZipPath ( array | null $metadata = null , array $extraFiles = []) : string
{
$zip = new ZipArchive ();
$tmpFile = tempnam ( sys_get_temp_dir (), 'bs-test-module' );
$zip -> open ( $tmpFile , ZipArchive :: CREATE );
$zip -> addFromString ( 'bookstack-module.json' , json_encode ( $metadata ? ? [
'name' => 'Test Module' ,
'description' => 'A test module for BookStack' ,
'version' => '1.0.0' ,
]));
foreach ( $extraFiles as $path => $contents ) {
$zip -> addFromString ( $path , $contents );
}
$zip -> close ();
return $tmpFile ;
}
}