From b67cdab8dc85cff3d6bd5b898cb5ed88fa4d2987 Mon Sep 17 00:00:00 2001 From: Lari Taskula Date: Fri, 31 Oct 2025 11:00:10 +0200 Subject: [PATCH] Bug 38365: (follow-up) Add more comprehensive unit tests Previous patch only covered OPAC interface. These tests cover both opac and intranet, as well as adding a test to ensure the other interface is still disabled while only the other is enabled. Adds more readable test output. To test: 1. prove t/db_dependent/Output.t Signed-off-by: David Cook --- t/db_dependent/Output.t | 278 ++++++++++++++++++++-------------------- 1 file changed, 138 insertions(+), 140 deletions(-) diff --git a/t/db_dependent/Output.t b/t/db_dependent/Output.t index bc0d2b78bfd..424701bdd90 100755 --- a/t/db_dependent/Output.t +++ b/t/db_dependent/Output.t @@ -162,166 +162,164 @@ subtest 'redirect_if_opac_suppressed() tests' => sub { }; subtest 'Content-Security-Policy tests' => sub { - plan tests => 1; - - subtest 'OPAC' => sub { - - plan tests => 3; - - subtest 'turned off' => sub { - - plan tests => 2; + my @interfaces = ( 'opac', 'intranet' ); + my $modes = { + 'enabled' => 'Content-security-policy', + 'report-only' => 'Content-security-policy-report-only', + }; + my @conf_modes = keys %$modes; - $schema->storage->txn_begin; + plan tests => scalar @interfaces; + foreach my $interface (@interfaces) { - t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + subtest "interface: $interface" => sub { - my $query = CGI->new(); + # # one subtest per mode + disabled + plan tests => scalar @conf_modes + 1; - { - local *STDOUT; - my $stdout; - open STDOUT, '>', \$stdout; + subtest 'disabled' => sub { - my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( - { - template_name => "opac-main.tt", - type => "opac", - query => $query, - authnotrequired => 1, - } - ); + $schema->storage->txn_begin; - C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); + t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); - unlike( $stdout, qr{Content-Security-Policy:}, 'No header present: Content-Security-Policy' ); - unlike( - $stdout, qr{Content-Security-Policy-Report-Only:}, - 'No header present: Content-Security-Policy-Report-Only' + t::lib::Mocks::mock_config( + 'content_security_policy', + { $interface => { csp_mode => '', csp_header_value => '' } } ); - close STDOUT; - }; - - $schema->storage->txn_rollback; - - }; + my $query = CGI->new(); - subtest 'report-only' => sub { + { + local *STDOUT; + my $stdout; + open STDOUT, '>', \$stdout; - plan tests => 3; + my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( + { + template_name => "${interface}-main.tt", + type => $interface, + query => $query, + authnotrequired => 1, + } + ); - $schema->storage->txn_begin; + C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); - t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + unlike( + $stdout, qr/Content-Security-Policy:/i, + "While feature is disabled, and requesting '$interface', no header present: 'Content-Security-Policy'" + ); + unlike( + $stdout, qr/Content-Security-Policy-Report-Only:/i, + "While feature is disabled, and requesting '$interface', no header present: 'Content-Security-Policy-Report-Only'" + ); - C4::Context->interface('opac'); + close STDOUT; + }; - my $expected_csp_header = 'Content-security-policy-report-only'; - my $expected_csp_header_value = - "default-src 'self'; script-src 'self' 'unsafe-eval' _CSP_NONCE_; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'"; - t::lib::Mocks::mock_config( - 'content_security_policy', - { 'opac' => { csp_mode => 'report-only', csp_header_value => $expected_csp_header_value } } - ); + $schema->storage->txn_rollback; - my $query = CGI->new(); - - { - local *STDOUT; - my $stdout; - open STDOUT, '>', \$stdout; - - my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( - { - template_name => "opac-main.tt", - type => "opac", - query => $query, - authnotrequired => 1, - } - ); - - my $cache = Koha::Cache::Memory::Lite->get_instance; - my $nonce = $cache->get_from_cache("csp_nonce"); - $expected_csp_header_value =~ s/_CSP_NONCE_/nonce-$nonce/g; - C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); - - $stdout =~ /(Content-Security-Policy[A-Za-z\-]*): (.*)\r/im; - my ( $csp_header, $csp_header_value ) = ( $1, $2 ); - isnt( - $csp_header, 'Content-Security-Policy', - 'No header present: Content-Security-Policy' - ); - is( $csp_header, $expected_csp_header, 'Header present: Content-Security-Policy-Report-Only' ); - is( - $csp_header_value, $expected_csp_header_value, - 'Content-Security-Policy-Report-Only value matches expected value' - ); - - close STDOUT; }; - $schema->storage->txn_rollback; - - }; - - subtest 'enabled' => sub { - - plan tests => 3; - - $schema->storage->txn_begin; - - t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); - - C4::Context->interface('opac'); - - my $expected_csp_header = 'Content-security-policy'; - my $expected_csp_header_value = - "default-src 'self'; script-src 'self' 'unsafe-eval' _CSP_NONCE_; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'"; - t::lib::Mocks::mock_config( - 'content_security_policy', - { 'opac' => { csp_mode => 'enabled', csp_header_value => $expected_csp_header_value } } - ); - - my $query = CGI->new(); - - { - local *STDOUT; - my $stdout; - open STDOUT, '>', \$stdout; - - my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( - { - template_name => "opac-main.tt", - type => "opac", - query => $query, - authnotrequired => 1, + foreach my $mode (@conf_modes) { + subtest "CSP mode: $mode" => sub { + + # # while enabled, but req.. # no header present # header present + value matches + plan tests => ( scalar @interfaces - 1 ) * 2 + ( scalar @conf_modes - 1 ) * 1 + 2; + + $schema->storage->txn_begin; + + t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' ); + + C4::Context->interface($interface); + + my $expected_csp_header = $modes->{$mode}; + my $expected_csp_header_value = + "default-src 'self'; script-src 'self' 'unsafe-eval' _CSP_NONCE_; style-src 'self' 'unsafe-inline' _CSP_NONCE_; img-src 'self' data:; font-src 'self'; object-src 'none'"; + t::lib::Mocks::mock_config( + 'content_security_policy', + { $interface => { csp_mode => $mode, csp_header_value => $expected_csp_header_value } } + ); + + my $query = CGI->new(); + + foreach my $other_interface (@interfaces) { + next if $other_interface eq $interface; + { + local *STDOUT; + my $stdout; + open STDOUT, '>', \$stdout; + + my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( + { + template_name => "${other_interface}-main.tt", + type => $other_interface, + query => $query, + authnotrequired => 1, + } + ); + + C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); + + unlike( + $stdout, qr/Content-Security-Policy:/i, + "While feature is enabled on '$interface', but requesting '$other_interface', where the feature is disabled, no header present: 'Content-Security-Policy'" + ); + unlike( + $stdout, qr/Content-Security-Policy-Report-Only:/i, + "While feature is enabled on '$interface', but requesting '$other_interface', where the feature is disabled, no header present: 'Content-Security-Policy-Report-Only'" + ); + + close STDOUT; + }; } - ); - - my $cache = Koha::Cache::Memory::Lite->get_instance; - my $nonce = $cache->get_from_cache("csp_nonce"); - $expected_csp_header_value =~ s/_CSP_NONCE_/nonce-$nonce/g; - - C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); - - $stdout =~ /(Content-Security-Policy[A-Za-z\-]*): (.*)\r/im; - my ( $csp_header, $csp_header_value ) = ( $1, $2 ); - isnt( - $csp_header, 'Content-Security-Policy-Report-Only:', - 'No header present: Content-Security-Policy-Report-Only' - ); - is( $csp_header, $expected_csp_header, 'Header present: Content-Security-Policy' ); - is( - $csp_header_value, $expected_csp_header_value, - 'Content-Security-Policy value matches expected value' - ); - close STDOUT; - }; - - $schema->storage->txn_rollback; + { + local *STDOUT; + my $stdout; + open STDOUT, '>', \$stdout; + + my ( $template, $borrowernumber, $cookie ) = C4::Auth::get_template_and_user( + { + template_name => "${interface}-main.tt", + type => $interface, + query => $query, + authnotrequired => 1, + } + ); + + my $cache = Koha::Cache::Memory::Lite->get_instance; + my $nonce = $cache->get_from_cache("csp_nonce"); + $expected_csp_header_value =~ s/_CSP_NONCE_/nonce-$nonce/g; + C4::Output::output_html_with_http_headers( $query, $cookie, $template->output ); + + $stdout =~ /(Content-Security-Policy[A-Za-z\-]*): (.*)\r/im; + my ( $csp_header, $csp_header_value ) = ( $1, $2 ); + foreach my $other_mode (@conf_modes) { + next if $other_mode eq $mode; + isnt( + $csp_header, $modes->{$other_mode}, + "While feature is in '$mode' mode, header is not present: '$modes->{$other_mode}'" + ); + } + + is( + $csp_header, $expected_csp_header, + "While feature is in '$mode' mode, header is present: '$expected_csp_header'" + ); + is( + $csp_header_value, $expected_csp_header_value, + "The value of header '$csp_header' matches the expected value" + ); + + close STDOUT; + }; + + $schema->storage->txn_rollback; + }; + } }; - }; + } }; -- 2.39.5